Generics
Generics in TypeScript
Generics enable you to write flexible, reusable code while still maintaining strong typing. They are a foundational aspect of TypeScript and many modern programming languages.
What are generics?
Generics are a tool that allows you to write functions, classes, and interfaces that work with a variety of types while maintaining type safety. Instead of committing to a single data type, generics allow you to work with any type.
Here's a simple example of a function using generics:
function identity<T>(arg: T): T {
return arg;
}
In this example, T
is a type variable—a kind of placeholder for any type. When you call the identity
function, TypeScript will replace T
with the actual type you use.
Why are generics important in TypeScript?
Generics are important in TypeScript because they allow for reusable and scalable code. By using generics, a developer can create components that work over a variety of types rather than a single one.
Consider this scenario:
function stringIdentity(arg: string): string {
return arg;
}
This function can only accept strings. If you want to use this function with numbers, you'd need to create a new one. Using generics, you can handle multiple types with a single function, improving scalability and reusability.
Benefits of using generics
Generics offer several key benefits:
- Type Safety: Generics provide type safety without the need to know the exact type at compile time. They ensure that the type you put in is the same type you get out.
- Code Reusability: With generics, you write less code and have less repetition in your codebase. You can write a single function, class, or interface that can handle a variety of types.
- Flexible Interfaces: Generics allow you to define the most appropriate interfaces for your code.
Generic Types
When you use generics, TypeScript replaces the type variables with the actual types you're using. This replacement is known as the generic type.
For example:
let myIdentity: <T>(arg: T) => T = identity;
In this example, myIdentity
has the type of a generic function. TypeScript knows that T
is a placeholder, and it will replace it with the actual type when myIdentity
is called.
Declaring generic types in TypeScript
To declare a generic type, you use a type variable—a kind of placeholder for any type.
Here's a simple example:
function identity<T>(arg: T): T {
return arg;
}
In this example, T
is the type variable. When you call identity
, TypeScript will replace T
with the actual type you use.
Type inference with generics
TypeScript can infer the type of a generic function based on the argument you pass to it. This feature is known as type inference.
Here's an example:
let output = identity("TypeScript"); // type of output will be inferred as string
In this example, TypeScript infers that T
must be string
because you're calling identity
with a string.
Using type parameters and constraints
Type parameters and constraints provide additional flexibility when working with generics. They allow you to specify characteristics the types used in generic functions, classes, and interfaces must have.
Here's an example of a function that uses a type parameter and a constraint:
function loggingIdentity<T extends Array<any>>(arg: T): T {
console.log(arg.length); // Array has a .length, so no more error
return arg;
}
In this example, the constraint extends Array<any>
requires that T
be an array.
Generic Functions
A generic function is a function that can work with any type. Here's a simple example of a generic function:
function identity<T>(arg: T): T {
return arg;
}
In this example, identity
is a generic function. It uses the type variable T
to allow it to work with any type.
Type inference and function overloads
Type inference and function overloads work together in TypeScript to provide powerful and flexible type checking. Function overloads allow you to define multiple signatures for a function, and type inference lets TypeScript choose the appropriate one based on the types of the arguments you pass.
Here's an example:
function identity<T>(arg: T): T;
function identity(arg: any): any;
function identity(arg: any) {
return arg;
}
let output = identity("TypeScript"); // type of output will be inferred as string
In this example, TypeScript infers that the T
in the first overload must be string
because you're calling identity
with a string.
Using type parameters in function signatures
You can use type parameters in function signatures to allow your functions to handle a variety of types while maintaining type safety.
Here's an example:
function identity<T>(arg: T): T {
return arg;
}
In this example, identity
uses the type parameter T
in its signature. This allows it to work with any type.
Generic classes
Generic classes work much like generic interfaces and functions. A generic class has a similar shape to a class, with a generic type list (e.g., <T>
).
Here's an example:
class GenericNumber<T> {
zeroValue: T;
add: (x: T, y: T) => T;
}
In this example, GenericNumber
is a class with the generic type T
. It can work with any type: numbers, strings, etc.
Type Constraints and Bounds
Type constraints and bounds provide additional control over the types you can use with generics.
Applying constraints on generic types
A constraint limits the types you can use with a generic type variable. You define a constraint with the extends
keyword.
Here's an example:
function loggingIdentity<T extends Array<any>>(arg: T): T {
console.log(arg.length); // Array has a .length, so no more error
return arg;
}
In this example, T extends Array<any>
is a constraint that requires T
to be an array.
Using type bounds to restrict generic parameters
Type bounds are a kind of constraint that restricts what types a type variable can accept.
Here's an example:
function copyFields<T extends U, U>(target: T, source: U): T {
for (let id in source) {
target[id] = (<T>source)[id];
}
return target;
}
In this example, the type bound T extends U
requires that T
be a subtype of U
.
Working with keyof and keyof typeof operators
The keyof
and keyof typeof
operators in TypeScript allow you to use property names as types and provide type safety when accessing properties.
Here's an example:
function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
let x = {a: 1, b: 2, c: 3, d: 4};
getProperty(x, "a"); // okay
getProperty(x, "m
"); // error: Argument of type '"m"' is not assignable to parameter of type '"a" | "b" | "c" | "d"'.
In this example, K extends keyof T
is a constraint that requires K
to be a key of T
.
Advanced Generic Techniques
Advanced generic techniques include using mapped types and conditional types to create more flexible, reusable, and type-safe code.
Mapped types and conditional types
Mapped types allow you to transform the properties in an old type into a new type. Conditional types allow you to select one of two possible types based on a condition.
Here's an example of a mapped type:
type Readonly<T> = {
readonly [P in keyof T]: T[P];
}
type Partial<T> = {
[P in keyof T]?: T[P];
}
In this example, Readonly<T>
is a mapped type that makes all properties of T
readonly. Partial<T>
is a mapped type that makes all properties of T
optional.
Type inference with conditional types
Type inference with conditional types allows TypeScript to infer the type of a value based on a condition.
Here's an example:
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends Function ? "function" :
"object";
type T0 = TypeName<string>; // "string"
type T1 = TypeName<1>; // "number"
In this example, TypeName<T>
is a conditional type that selects a string literal type based on the type of T
.
Using infer to extract type information
The infer
keyword in TypeScript allows you to infer types within conditional types.
Here's an example:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
type T0 = ReturnType<() => string>; // string
In this example, ReturnType<T>
is a conditional type that infers the return type of a function type T
.