Learn about TypeScript generics, including their use in functions, classes, interfaces, and type constraints. This guide covers key concepts like type variance, conditional types, and how to create reusable, type-safe code for flexible applications. Explore advanced topics such as reflection, the infer keyword, and more.
1. Generics in TypeScript
Generics in TypeScript allow us to create reusable, type-safe code that can work with a variety of types. They enable us to write functions, classes, and interfaces that can operate on different data types without losing type information. This makes the code more flexible and reusable.
Example of Generics:
Let's start by writing a simple function that takes an argument of any type T
and returns the same type:
function identity<T>(arg: T): T {
return arg;
}
const result1 = identity("Hello, world!"); // T is inferred as string
const result2 = identity(42); // T is inferred as number
console.log(result1, result2); // Output: "Hello, world!" 42
Here, the type T
is a placeholder for any type. The function identity
works for any type, and it returns that type. This is the essence of generics — making a function work for many types while keeping the types safe.
2. Generics with Type Constraints
Sometimes, we want to limit the types that a generic function can accept. Type constraints allow us to specify that a type parameter must extend from a certain type, or meet specific criteria. This ensures that we can only pass certain kinds of types into the generic function.
Example of Type Constraints:
function echo<T extends string | number>(value: T): T {
return value;
}
const stringValue = echo("Hello"); // Works, T is string
const numberValue = echo(42); // Works, T is number
// const booleanValue = echo(true); // Error: Type 'boolean' is not assignable to type 'string | number'
In this example, the generic function echo
is constrained to accept only string
or number
types. If we try to pass a boolean
or any other type, TypeScript will throw an error.
3. Generic Interfaces
Interfaces with generics allow us to define reusable contracts (shapes) for objects and classes that work with a variety of types. This is useful when you want to ensure that certain objects follow a consistent structure, but with flexibility in the type of data they hold.
Example of Generic Interface:
interface Repository<T, U> {
items: T[]; // Array of items of type T
add(item: T): void; // Method to add an item of type T
getById(id: U): T | undefined; // Method to get an item by ID of type U
}
interface User {
id: number;
name: string;
}
class UserRepository implements Repository<User, number> {
items: User[] = [];
add(item: User): void {
this.items.push(item);
}
getById(id: number): User | undefined {
return this.items.find((user) => user.id === id);
}
}
const userRepo = new UserRepository();
userRepo.add({ id: 1, name: "Alice" });
const user = userRepo.getById(1);
console.log(user); // Output: { id: 1, name: "Alice" }
Here, the Repository
interface is defined as a generic interface with two type parameters: T
(the type of items in the repository) and U
(the type of the ID). The UserRepository
class implements this interface for User
objects, ensuring that it can handle items of type User
and IDs of type number
.
4. Generic Classes
Just like interfaces, classes can also be made generic, allowing the class to work with various types. When using generics in classes, all the properties and methods can be typed dynamically based on the provided type.
Example of a Generic Class:
interface User {
id: number;
name: string;
age: number;
}
class UserDetails<T extends User> {
id: T["id"];
name: T["name"];
age: T["age"];
constructor(user: T) {
this.id = user.id;
this.name = user.name;
this.age = user.age;
}
getUserDetails(): string {
return `User: ${this.name}, ID: ${this.id}, Age: ${this.age}`;
}
updateName(newName: string): void {
this.name = newName;
}
updateAge(newAge: number): void {
this.age = newAge;
}
}
// Using the UserDetails class with a User type
const user: User = { id: 1, name: "Alice", age: 30 };
const userDetails = new UserDetails(user);
console.log(userDetails.getUserDetails()); // Output: "User: Alice, ID: 1, Age: 30"
// Updating user details
userDetails.updateName("Bob");
userDetails.updateAge(35);
console.log(userDetails.getUserDetails()); // Output: "User: Bob, ID: 1, Age: 35"
In this example, the UserDetails
class is defined as a generic class that expects a type T
extending User
. The class has properties and methods that are based on the User
type, but it works with any object that conforms to User
.
5. Constraining Type Parameters to Passed Types
At times, we may want to constrain the types based on other types passed to the function. This can be achieved using keyof
and TypeScript's advanced type constraints.
Example:
function getProperty<Type, Key extends keyof Type>(
obj: Type,
key: Key
): Type[Key] {
return obj[key];
}
const person = { name: "Alice", age: 30, address: "123 Main St" };
const name = getProperty(person, "name"); // Works: type is string
const age = getProperty(person, "age"); // Works: type is number
// const invalid = getProperty(person, "email"); // Error: 'email' does not exist on type 'person'
In this case, getProperty
is constrained to only accept keys that exist on the object Type
. Trying to access a non-existent property (like "email"
) will result in an error.
6. Conditional Types
Conditional types allow us to define a type based on a condition. They work similarly to ternary operators, but for types.
Simple Example:
type IsString<T> = T extends string ? "String" : "Not String";
let result1: IsString<string>; // "String"
let result2: IsString<number>; // "Not String"
Advanced Example:
type HasProperty<T, K extends keyof T> = K extends "age"
? "Has Age"
: "Has Name";
interface User {
name: string;
age: number;
}
let test1: HasProperty<User, "age">; // "Has Age"
let test2: HasProperty<User, "name">; // "Has Name"
Here, the conditional type checks if a certain key ("age"
) exists in the type User
. If it does, it returns "Has Age"
, otherwise it returns "Has Name"
.
7. Intersection Types
Intersection types allow you to combine multiple types into one. A value of an intersection type will have all properties from the combined types.
Example:
interface MentalWellness {
mindfulnessPractice: boolean;
stressLevel: number;
}
interface PhysicalWellness {
exerciseFrequency: string;
sleepDuration: number;
}
interface Productivity {
tasksCompleted: number;
focusLevel: number;
}
type HealthyBody = MentalWellness & PhysicalWellness & Productivity;
const person: HealthyBody = {
mindfulnessPractice: true,
stressLevel: 4,
exerciseFrequency: "daily",
sleepDuration: 7,
tasksCompleted: 15,
focusLevel: 8,
};
console.log(person);
In this example, the HealthyBody
type combines the properties of MentalWellness
, PhysicalWellness
, and Productivity
into a single type, which is then used to define a person
object.
8. Infer Keyword
The infer
keyword allows TypeScript to automatically infer a type within a conditional type. This is useful when you want to extract the type from a wrapped type, such as a Promise
.
Example:
type ReturnTypeOfPromise<T> = T extends Promise<infer U> ? U : never;
type Result = ReturnTypeOfPromise<Promise<string>>; // Result is 'string'
type ErrorResult = ReturnTypeOfPromise<number>; // ErrorResult is 'never'
const result: Result = "Hello";
console.log(typeof result); // Output: 'string'
Here, the ReturnTypeOfPromise
type uses infer
to extract the type U
from a Promise<T>
. If the type T
is not a Promise
, it returns never
.
9. Type Variance: Covariance and Contravariance
Type variance deals with how subtypes and supertypes relate to each other in terms of assignment compatibility. There are two main types of variance:
- Covariance: A subtype can be used where a supertype is expected.
- Contravariance: A supertype can be used where a subtype is expected.
Covariance Example:
class Vehicle {
start() {
console.log("Vehicle is running");
}
}
class Car extends Vehicle {
honk() {
console.log("Car honks");
}
}
function vehiclefunc(vehicle: Vehicle) {
vehicle.start();
}
function carfunc(car: Car) {
car.start();
car.honk();
}
let car: Car = new Car();
vehiclefunc(car); // Works due to covariance
In this case, Car
is a subclass of Vehicle
, so you can use a Car
where a Vehicle
is expected.
Contravariance Example:
class Vehicle {
startEngine() {
console.log("Vehicle engine starts");
}
}
class Car extends Vehicle {
honk() {
console.log("Car honks");
}
}
function processVehicle(vehicle: Vehicle) {
vehicle.startEngine();
}
function processCar(car: Car) {
car.startEngine();
car.honk();
}
let car: Car = new Car();
processVehicle(car); // Works due to contravariance
In this example, Car
can be passed to a function that expects Vehicle
, because of contravariance. However, if you expect specific subtype behavior (like calling honk()
), it can lead to an error.
10. Reflection in TypeScript
TypeScript generally focuses on compile-time type checking, but it also provides runtime checks through operators like typeof
and instanceof
.
Example:
const num = 23;
console.log(typeof num); // "number"
const flag = true;
console.log(typeof flag); // "boolean"
class Vehicle {
model: string;
constructor(model: string) {
this.model = model;
}
}
const benz = new Vehicle("Mercedes-Benz");
console.log(benz instanceof Vehicle); // true
The typeof
operator is used for primitive types, while instanceof
is used to check if an object is an instance of a particular class.
Conclusion
TypeScript's type system, with features like generics, type constraints, conditional types, and type variance, allows you to write flexible, reusable, and type-safe code. By combining these features, we can create highly maintainable applications that are both powerful and flexible while avoiding runtime errors. The introduction of dependency injection and the use of interfaces also promote loose coupling and make your code more testable.