TypeScript generics are easy to learn and hard to master. The official docs give you the basics — <T>, constraints with extends, and a few built-in utility types. But real-world TypeScript demands more.
This article covers the patterns I've extracted from maintaining a large TypeScript codebase over several years. These are the patterns that separate "it compiles" from "the type system is working for me."
1. Generic Constraints Beyond the Basics
The standard extends constraint ensures a type parameter has certain properties:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
But you can constrain to shapes, not just keys:
interface Identifiable {
id: string;
}
class Repository<T extends Identifiable> {
private items = new Map<string, T>();
save(item: T): void {
this.items.set(item.id, item);
}
get(id: string): T | undefined {
return this.items.get(id);
}
}
Advanced trick: Constrain to a constructor signature to build factories:
type Constructor<T> = new (...args: any[]) => T;
function createInstance<T>(
ctor: Constructor<T>,
...args: ConstructorParameters<Constructor<T>>
): T {
return new ctor(...args);
}
2. Conditional Types: If/Else for Types
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // false
The real power comes from chaining and the infer keyword:
// Extract element type from an array
type ElementType<T> = T extends (infer U)[] ? U : never;
type C = ElementType<string[]>; // string
3. The infer Keyword
// Extract the return type of a function
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;
// Extract the type of the first parameter
type FirstParam<T> = T extends (arg: infer P, ...rest: any[]) => any ? P : never;
Real-world use case: Type-safe event emitter
type EventMap = {
userCreated: { id: string; name: string };
userDeleted: { id: string };
error: { message: string; code: number };
};
class TypedEmitter<T extends Record<string, any>> {
on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void { }
emit<K extends keyof T>(event: K, payload: T[K]): void { }
}
const emitter = new TypedEmitter<EventMap>();
emitter.on('userCreated', (user) => {
console.log(user.name); // type-safe
});
4. Mapped Types
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Optional<T> = {
[K in keyof T]?: T[K];
};
Key remapping with as (TypeScript 4.1+):
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type User = { name: string; age: number };
type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }
5. Template Literal Types
type Color = 'red' | 'green' | 'blue';
type Brightness = 'light' | 'dark';
type ThemeColor = `${Brightness}-${Color}`;
// 'light-red' | 'light-green' | ... | 'dark-blue'
Production pattern: Spacing props
type Spacing = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
type Direction = 't' | 'r' | 'b' | 'l';
type MarginProp = `m${Direction}${Spacing}`;
type PaddingProp = `p${Direction}${Spacing}`;
6. Branded Types
type Brand<T, B> = T & { __brand: B };
type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;
function getUser(id: UserId): User { /* ... */ }
function getPost(id: PostId): Post { /* ... */ }
const userId = 'abc' as UserId;
getUser(userId); // OK
getUser(postId); // Error!
7. Generic Type Guards
function isOfType<T>(
obj: unknown,
keys: (keyof T)[]
): obj is T {
return keys.every((key) => key in obj);
}
interface User { id: string; email: string; }
const data: unknown = JSON.parse(payload);
if (isOfType<User>(data, ['id', 'email'])) {
console.log(data.email); // type-safe
}
Summary
| Pattern | When to Use | Key Syntax |
|---|---|---|
| Constrained generics | Ensuring type params have properties | <T extends { id: string }> |
| Conditional types | Type-level branching | T extends U ? X : Y |
infer |
Extracting types from other types | infer R |
| Mapped types | Transforming object shapes | [K in keyof T]: T[K] |
| Template literals | String union manipulation | ${A}-${B} |
| Branded types | Distinguishing same-shape types | T & { __brand: B } |
Generics are not about making code more complex. They're about making code safer by letting the compiler catch mistakes that would otherwise become runtime errors.