Explore the power of generics in TypeScript to create flexible and reusable code. Learn how to use generic functions, classes, and interfaces with practical examples and best practices.
Generics are a powerful feature in TypeScript that allow developers to create components that can work with a variety of data types while maintaining type safety. By enabling the creation of reusable and flexible code, generics play a crucial role in building scalable and maintainable applications. In this section, we will delve deep into the concept of generics, explore their syntax, and provide practical examples to illustrate their capabilities.
Generics provide a way to create reusable components in TypeScript by allowing you to define functions, classes, and interfaces that can operate with different data types. This is achieved by using type variables, which act as placeholders for specific types that are provided when the component is used.
The syntax for declaring generics in TypeScript involves using angle brackets (<T>
) to define a type variable. This variable can be used throughout the function, class, or interface to represent the type that will be provided by the user.
A generic function can accept any type of argument and return a value of the same type. Here’s a simple example:
function identity<T>(arg: T): T {
return arg;
}
let output1 = identity<string>("Hello, TypeScript!");
let output2 = identity<number>(42);
In this example, identity
is a generic function that takes a type parameter T
. The function returns a value of the same type as the argument it receives. This allows identity
to work with any data type while maintaining type safety.
Generics can also accept multiple type parameters, allowing for more complex interactions between types:
function map<K, V>(key: K, value: V): [K, V] {
return [key, value];
}
let pair = map<string, number>("age", 30);
Here, the map
function takes two type parameters K
and V
, representing the types of the key and value, respectively.
Generics are not limited to functions; they can also be used with classes and interfaces to create flexible data structures.
A generic class can operate on any data type specified at the time of instantiation:
class Box<T> {
private contents: T;
constructor(contents: T) {
this.contents = contents;
}
getContents(): T {
return this.contents;
}
}
let stringBox = new Box<string>("Hello");
let numberBox = new Box<number>(123);
In this example, Box
is a generic class that can hold any type of content, specified by the type parameter T
.
Generic interfaces allow you to define flexible contracts that can be used with different types:
interface Pair<K, V> {
key: K;
value: V;
}
let stringNumberPair: Pair<string, number> = { key: "age", value: 30 };
The Pair
interface defines a contract for objects that have a key
and a value
, both of which can be of any type specified by K
and V
.
Sometimes, you need to restrict the types that can be used with a generic. This is where constraints come into play, using the extends
keyword.
Constraints allow you to specify that a type parameter must extend a particular type or interface:
function logLength<T extends { length: number }>(arg: T): void {
console.log(arg.length);
}
logLength("Hello"); // Works, string has length
logLength([1, 2, 3]); // Works, array has length
// logLength(42); // Error, number has no length property
In this example, the logLength
function accepts any type T
that has a length
property, ensuring that only types with this property can be used.
Generics are widely used in TypeScript for various scenarios, including collections, utility functions, and data structures.
Generics are ideal for creating collections that can store any type of data:
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
}
let numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
console.log(numberStack.pop()); // 20
Utility functions that perform operations on different data types can benefit from generics:
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
let mergedObject = merge({ name: "Alice" }, { age: 30 });
console.log(mergedObject); // { name: "Alice", age: 30 }
TypeScript provides several built-in generic interfaces, such as Array<T>
and Promise<T>
, which are commonly used in everyday programming.
Array<T>
The Array<T>
interface represents an array of elements of type T
:
let numbers: Array<number> = [1, 2, 3, 4];
Promise<T>
The Promise<T>
interface represents a promise that resolves to a value of type T
:
let promise: Promise<string> = new Promise((resolve) => {
resolve("Hello, World!");
});
While generics offer flexibility, they can also present challenges, particularly in type inference and providing explicit type arguments.
TypeScript often infers the type of generic parameters based on the arguments provided, but sometimes you need to specify them explicitly:
function wrapInArray<T>(value: T): T[] {
return [value];
}
let inferredArray = wrapInArray(10); // Type is number[]
let explicitArray = wrapInArray<number>(20); // Type is number[]
You can provide default types for generic parameters to make APIs more flexible:
function createArray<T = string>(length: number, value: T): T[] {
return Array(length).fill(value);
}
let stringArray = createArray(3, "Hello"); // Default type is string
let numberArray = createArray<number>(3, 42); // Explicitly specify number
When using generics, it’s essential to balance flexibility with readability and maintainability.
Use descriptive names for type parameters, such as T
, K
, V
, to convey their purpose:
T
for a general typeK
for a key typeV
for a value typeGenerics enhance IDE tooling by providing better type inference and code navigation, making it easier to understand and maintain code.
Generics can be combined with other TypeScript features to create advanced patterns, such as polymorphic this
types or partial types.
this
TypesPolymorphic this
types allow you to define methods that return this
, enabling method chaining in subclasses:
class FluentBuilder<T> {
private instance: T;
constructor(instance: T) {
this.instance = instance;
}
set<K extends keyof T>(key: K, value: T[K]): this {
this.instance[key] = value;
return this;
}
build(): T {
return this.instance;
}
}
let builder = new FluentBuilder({ name: "", age: 0 });
let person = builder.set("name", "Alice").set("age", 30).build();
Partial types allow you to create objects with optional properties:
interface Person {
name: string;
age: number;
}
type PartialPerson = Partial<Person>;
let partial: PartialPerson = { name: "Alice" }; // age is optional
Generics can be combined with union types, type aliases, and other TypeScript features to create robust type systems.
Generics can work with union types to create flexible APIs:
function getLength<T extends string | any[]>(arg: T): number {
return arg.length;
}
console.log(getLength("Hello")); // 5
console.log(getLength([1, 2, 3])); // 3
Type aliases can be used with generics to create reusable type definitions:
type Result<T> = { success: boolean; data: T };
let result: Result<number> = { success: true, data: 42 };
Generics play a vital role in building scalable and maintainable applications by enabling code reuse and abstraction. By mastering generics, you can create flexible APIs and data structures that adapt to different requirements without compromising type safety.
Generics are an essential feature of TypeScript that allow developers to write flexible, reusable, and type-safe code. By understanding the syntax, use cases, and best practices for generics, you can leverage their full potential to build robust applications. Practice using generics in your projects to deepen your understanding and explore advanced patterns to enhance your TypeScript skills.