TypeScript Generics: Advanced Patterns for Production Codebases
Learn practical TypeScript generics patterns — constraints, conditional types, mapped types — used in real production apps, not toy examples.
TypeScript generics are where most intermediate developers plateau. The basic function identity(arg: T): T examples in tutorials do not prepare you for the real challenges: typing a paginated API client, modeling event emitters that link names to payloads, or writing a utility that strips undefined from all properties of an object.
This guide covers the advanced TypeScript generics patterns that appear repeatedly in production codebases — with the reasoning behind each one.
TypeScript Generics Patterns That Matter in Production
Constrained Generics for Utility Functions
The extends keyword in a type parameter is a constraint, not inheritance. It means "T must be assignable to this shape."
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30 };
const name = pluck(user, "name"); // string — correct
const age = pluck(user, "age"); // number — correct
// pluck(user, "email") // compile error — correctWithout the K extends keyof T constraint, key would be typed as string and TypeScript could not infer the return type accurately. The constraint is what enables the precise inference.
Generic Interfaces for API Responses
Define your response envelope once and reuse it across every endpoint:
interface ApiResponse<T> {
data: T;
meta: {
total: number;
page: number;
pageSize: number;
};
error: null;
}
interface ApiError {
data: null;
error: {
code: string;
message: string;
};
}
type Result<T> = ApiResponse<T> | ApiError;Callers receive Result<User[]> or Result<Order>. TypeScript narrows the type when they check if (result.error !== null). No casting required.
Conditional Types for Transformation Utilities
Conditional types let you branch on type-level conditions:
type NonNullable<T> = T extends null | undefined ? never : T;
type Flatten<T> = T extends Array<infer U> ? U : T;
type Flatten_string = Flatten<string[]>; // string
type Flatten_number = Flatten<number>; // number (not an array, returns as-is)The infer keyword inside a conditional type captures a type variable within the match. This is how the standard library's ReturnType<T> and Parameters<T> are implemented.
Mapped Types for Property Transformations
Mapped types iterate over the keys of a type and transform each property:
type Partial<T> = {
[K in keyof T]?: T[K];
};
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
// Practical: make all nested properties optional
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};DeepPartial is useful for update payloads where callers send only the fields they want to change. Without it, you end up with Partial<User> which does not recurse into nested objects.
Typed Event Emitters
One of the most valuable generic patterns in a Node.js or browser application is a type-safe event emitter:
type EventMap = {
"user:created": { id: string; email: string };
"order:shipped": { orderId: string; trackingNumber: string };
"payment:failed": { orderId: string; reason: string };
};
class TypedEmitter<Events extends Record<string, unknown>> {
private listeners = new Map<keyof Events, Set<(payload: unknown) => void>>();
on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(handler as (payload: unknown) => void);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.listeners.get(event)?.forEach(handler => handler(payload));
}
}
const emitter = new TypedEmitter<EventMap>();
emitter.on("user:created", ({ id, email }) => {
// id and email are correctly typed — no casting
});
emitter.emit("order:shipped", {
orderId: "123",
trackingNumber: "TRK456"
});
// emitter.emit("order:shipped", { orderId: "123" }) — compile error: missing trackingNumberIf you rename an event or change its payload shape, every broken handler is flagged at compile time. This is the kind of safety that pays off in a large codebase with many event producers and consumers.
When Not to Use Generics
The most common mistake with generics is reaching for them when a concrete type works fine. Generics add cognitive overhead and can make error messages harder to read.
Ask yourself: "Does this function need to work with multiple different types?" If the answer is always the same type, use that type directly. If you are fighting TypeScript to express what you want in a generic, a simpler approach almost certainly exists.
Start with one type parameter. Add a second only when you need to model a relationship between two types (like K extends keyof T). Add conditional types and mapped types only when simpler patterns cannot express the constraint.
If your team is building a TypeScript-first platform and wants the type system working for you rather than against you, Clixo can help you design the right foundations.