TypeScript Generics: A Practical Guide From First Principles
Generics explained by what problem they solve, with constraints, defaults, inference and the conditional types you meet in real codebases.
Table of contents
- Constraints: extends means "at least"
- Defaults
- Conditional types
- A real pattern: a typed event emitter
- Frequently asked questions
- When should I not use a generic?
- What is the difference between any and unknown?
- Why does my generic infer the wide type?
- Can I constrain a generic to specific literal types?
- Related reading
- References
A generic is a type parameter — a placeholder the caller fills in. The problem it solves is the one any solves badly: writing a function that works with many types without discarding type information.
// Loses everything
function firstAny(items: any[]): any {
return items[0];
}
// Preserves the relationship between input and output
function first<T>(items: T[]): T | undefined {
return items[0];
}
const name = first(['a', 'b']); // string | undefined
const count = first([1, 2]); // number | undefinedThe caller writes no type annotation — TypeScript infers T from the argument. That inference is what makes generics pleasant rather than ceremonial.
Constraints: extends means "at least"#
An unconstrained T can be anything, so you can do almost nothing with it. extends narrows what is allowed in:
// Error: T might not have .length
function longest<T>(a: T, b: T): T {
return a.length > b.length ? a : b;
}
// Works: T must have a numeric length
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length > b.length ? a : b;
}
longest('short', 'longer'); // string
longest([1], [1, 2]); // number[]
longest(1, 2); // Error — numbers have no lengthThe most useful constraint in practice is keyof:
function get<T, K extends keyof T>(object: T, key: K): T[K] {
return object[key];
}
const user = { id: '1', age: 30 };
get(user, 'age'); // number
get(user, 'nope'); // Error — 'nope' is not a key of userThis is a fully type-safe property getter, and it is impossible to express without generics.
Defaults#
type ApiResponse<TData = unknown> = {
data: TData;
error: string | null;
};
type Anything = ApiResponse; // TData is unknown
type Users = ApiResponse<User[]>; // TData is User[]Default to unknown, not any — unknown forces the consumer to narrow before use, which is the entire point of having types.
Conditional types#
T extends U ? X : Y at the type level. You meet these more than you write them, but they are worth reading fluently:
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type A = Unwrap<Promise<string>>; // string
type B = Unwrap<number>; // numberinfer introduces a type variable captured from the match. That single mechanism is how ReturnType, Parameters and Awaited are implemented.
A real pattern: a typed event emitter#
Generics pay for themselves when they eliminate a whole class of runtime error:
type EventMap = {
login: { userId: string };
logout: undefined;
error: { code: number; message: string };
};
class Emitter<TEvents extends Record<string, unknown>> {
private handlers: {
[K in keyof TEvents]?: Array<(payload: TEvents[K]) => void>;
} = {};
on<K extends keyof TEvents>(event: K, handler: (payload: TEvents[K]) => void) {
(this.handlers[event] ??= []).push(handler);
}
emit<K extends keyof TEvents>(event: K, payload: TEvents[K]) {
this.handlers[event]?.forEach((handler) => handler(payload));
}
}
const events = new Emitter<EventMap>();
events.on('login', (payload) => console.log(payload.userId)); // typed
events.emit('login', { userId: '1' }); // checked
events.emit('login', { user: '1' }); // Error
events.emit('typo', undefined); // ErrorBoth the event name and its payload are checked. No string typo and no mismatched payload can reach runtime.
Frequently asked questions#
When should I not use a generic?#
When the type parameter appears exactly once. function log<T>(value: T): void is just function log(value: unknown): void written with extra steps. A generic earns its place by relating two or more positions.
What is the difference between any and unknown?#
any disables checking; unknown requires narrowing before use. In a generic default or a catch clause, unknown is almost always correct.
Why does my generic infer the wide type?#
Usually because the argument is a mutable variable. const assertions help: f(['a', 'b'] as const) infers the literal tuple instead of string[].
Can I constrain a generic to specific literal types?#
Yes: <T extends 'a' | 'b'>. Combined with a default this gives you a checked enum without the runtime cost of a TypeScript enum.
Related reading#
- TypeScript Utility Types — built on
inferand conditional types - Type vs Interface