Mastering TypeScript Generics: A Practical Guide
Introduction
TypeScript generics are one of the most powerful features of the language, allowing you to write flexible, reusable code while maintaining type safety. In this guide, we'll explore generics from the ground up with practical examples.
What Are Generics?
Generics provide a way to make components work with any data type and not restrict to one data type. They help you write reusable code that can work with different types while still being type-safe.
// Without generics - limited to specific type
function getFirstNumber(arr: number[]): number | undefined {
return arr[0];
}
// With generics - works with any type
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
// Usage
const firstNumber = getFirst([1, 2, 3]); // Type: number | undefined
const firstString = getFirst(['a', 'b', 'c']); // Type: string | undefined
Generic Functions
Generic functions are the most common use case for generics. They allow you to write functions that can work with multiple types.
Basic Generic Function
function identity<T>(arg: T): T {
return arg;
}
// The compiler infers the type
const num = identity(42); // Type: 42
const str = identity("hello"); // Type: "hello"
// Or you can be explicit
const explicit = identity<string>("world"); // Type: string
Multiple Type Parameters
You can use multiple type parameters in a single function:
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const result = pair("hello", 42); // Type: [string, number]
Generic Interfaces
Interfaces can also be generic, allowing you to define contracts for objects that work with different types.
interface Container<T> {
value: T;
getValue: () => T;
setValue: (newValue: T) => void;
}
class NumberContainer implements Container<number> {
constructor(public value: number) {}
getValue(): number {
return this.value;
}
setValue(newValue: number): void {
this.value = newValue;
}
}
Generic Classes
Generic classes allow you to create reusable class definitions that work with different types.
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
isEmpty(): boolean {
return this.items.length === 0;
}
}
// Usage
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
console.log(numberStack.pop()); // 2
const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");
Generic Constraints
Sometimes you want to limit the types that can be used with a generic. You can do this with constraints using the extends
keyword.
interface Lengthwise {
length: number;
}
function logLength<T extends Lengthwise>(arg: T): T {
console.log(arg.length);
return arg;
}
// Works with any type that has a length property
logLength("hello"); // OK, strings have length
logLength([1, 2, 3]); // OK, arrays have length
logLength({ length: 10, value: 3 }); // OK, has length property
// logLength(3); // Error: number doesn't have length
Using Type Parameters in Constraints
You can use one type parameter to constrain another:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = { name: "Alice", age: 30, email: "[email protected]" };
const name = getProperty(person, "name"); // Type: string
const age = getProperty(person, "age"); // Type: number
// getProperty(person, "invalid"); // Error: "invalid" is not a key of person
Utility Types with Generics
TypeScript provides several built-in utility types that use generics:
Partial<T>
Makes all properties of T optional:
interface User {
id: number;
name: string;
email: string;
}
function updateUser(id: number, updates: Partial<User>): void {
// Implementation
}
// Can update any subset of properties
updateUser(1, { name: "New Name" });
updateUser(2, { email: "[email protected]", name: "Another Name" });
Pick<T, K>
Creates a type by picking specific properties from T:
type UserPreview = Pick<User, "id" | "name">;
// Equivalent to: { id: number; name: string; }
const preview: UserPreview = {
id: 1,
name: "Alice"
// email is not included
};
Record<K, T>
Creates an object type with keys of type K and values of type T:
type Role = "admin" | "user" | "guest";
type Permissions = Record<Role, string[]>;
const permissions: Permissions = {
admin: ["read", "write", "delete"],
user: ["read", "write"],
guest: ["read"]
};
Real-World Example: API Response Handler
Here's a practical example of using generics to create a type-safe API response handler:
interface ApiResponse<T> {
data: T | null;
error: string | null;
loading: boolean;
}
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async get<T>(endpoint: string): Promise<ApiResponse<T>> {
try {
const response = await fetch(`${this.baseUrl}${endpoint}`);
if (!response.ok) {
return {
data: null,
error: `HTTP error! status: ${response.status}`,
loading: false
};
}
const data = await response.json();
return {
data: data as T,
error: null,
loading: false
};
} catch (error) {
return {
data: null,
error: error instanceof Error ? error.message : "Unknown error",
loading: false
};
}
}
}
// Usage with type safety
interface User {
id: number;
name: string;
email: string;
}
const api = new ApiClient("https://api.example.com");
async function fetchUser(userId: number) {
const response = await api.get<User>(`/users/${userId}`);
if (response.data) {
console.log(response.data.name); // Type-safe access
console.log(response.data.email); // Type-safe access
}
}
Best Practices
1. Use Descriptive Type Parameter Names
While T
is common, use descriptive names for clarity:
// Good
function map<Input, Output>(
items: Input[],
fn: (item: Input) => Output
): Output[] {
return items.map(fn);
}
// Less clear
function map<T, U>(items: T[], fn: (item: T) => U): U[] {
return items.map(fn);
}
2. Start with Concrete Types
Don't over-engineer with generics. Start with concrete types and refactor to generics when you need reusability:
// Start with this
function processNumbers(items: number[]): number[] {
return items.map(x => x * 2);
}
// Refactor to generic when needed
function processItems<T>(
items: T[],
processor: (item: T) => T
): T[] {
return items.map(processor);
}
3. Use Constraints Wisely
Add constraints to ensure type safety and provide better IntelliSense:
function merge<T extends object, U extends object>(
obj1: T,
obj2: U
): T & U {
return { ...obj1, ...obj2 };
}
// Now TypeScript knows these must be objects
const result = merge(
{ name: "Alice" },
{ age: 30 }
); // Type: { name: string } & { age: number }
Common Pitfalls and Solutions
1. Losing Type Information
// Problem: Type information is lost
function badWrapper<T>(value: T) {
return { value }; // Return type is { value: T }
}
// Solution: Preserve the literal type
function goodWrapper<T>(value: T): { value: T } {
return { value };
}
const bad = badWrapper(5); // Type: { value: number }
const good = goodWrapper(5); // Type: { value: 5 } with const assertion
2. Overly Complex Generic Signatures
Keep generic signatures simple and readable:
// Too complex
function complex<T extends U, U extends V, V extends object>(
arg: T
): T extends U ? V : never {
// Implementation
}
// Better: break it down or simplify
function simple<T extends object>(arg: T): T {
// Implementation
}
Conclusion
TypeScript generics are a powerful tool for writing flexible, reusable, and type-safe code. Start with simple generic functions, gradually work your way up to generic classes and constraints, and always prioritize readability and maintainability.
Remember: the goal of generics is to write code once that works with multiple types, not to make your code more complex. Use them when they provide clear benefits in terms of reusability and type safety.
Happy coding!