TypeScript generics separate professional code from stringly-typed chaos. They let you write flexible, reusable components while keeping type safety. This guide covers the patterns you’ll actually use — not academic type gymnastics.
:::note[TL;DR]
- Generics add type parameters to functions, interfaces, and classes:
<T> - Constraints limit what types can be used:
<T extends { id: string }> - Conditional types create type branches:
T extends U ? X : Y - Mapped types transform object properties:
{ [K in keyof T]: V } - Utility types (Pick, Omit, Partial, Required) cover 80% of use cases
inferkeyword extracts types from complex structures :::
Basic Generics
Generic Functions
// Without generics — loses type information
function identity(arg: any): any {
return arg;
}
// With generics — preserves type
function identity<T>(arg: T): T {
return arg;
}
// Usage
const num = identity<number>(42); // Type: number
const str = identity<string>('hello'); // Type: string
const inferred = identity([1, 2, 3]); // Type: number[] (inferred)
Generic Interfaces
interface ApiResponse<T> {
data: T;
status: number;
error?: string;
}
// Usage
interface User {
id: string;
name: string;
}
const userResponse: ApiResponse<User> = {
data: { id: '1', name: 'Alice' },
status: 200
};
const listResponse: ApiResponse<User[]> = {
data: [{ id: '1', name: 'Alice' }],
status: 200
};
Generic Classes
class Queue<T> {
private items: T[] = [];
enqueue(item: T): void {
this.items.push(item);
}
dequeue(): T | undefined {
return this.items.shift();
}
peek(): T | undefined {
return this.items[0];
}
size(): number {
return this.items.length;
}
}
// Usage
const stringQueue = new Queue<string>();
stringQueue.enqueue('first');
stringQueue.enqueue('second');
const numberQueue = new Queue<number>();
numberQueue.enqueue(1);
numberQueue.enqueue(2);
Real-World Patterns
1. Repository Pattern with Generics
interface Entity {
id: string;
createdAt: Date;
updatedAt: Date;
}
class Repository<T extends Entity> {
private items: Map<string, T> = new Map();
create(item: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): T {
const now = new Date();
const newItem = {
...item,
id: crypto.randomUUID(),
createdAt: now,
updatedAt: now
} as T;
this.items.set(newItem.id, newItem);
return newItem;
}
findById(id: string): T | undefined {
return this.items.get(id);
}
findAll(): T[] {
return Array.from(this.items.values());
}
update(id: string, updates: Partial<Omit<T, 'id' | 'createdAt'>>): T | undefined {
const existing = this.items.get(id);
if (!existing) return undefined;
const updated = {
...existing,
...updates,
updatedAt: new Date()
};
this.items.set(id, updated);
return updated;
}
delete(id: string): boolean {
return this.items.delete(id);
}
}
// Usage
interface Product extends Entity {
name: string;
price: number;
inStock: boolean;
}
const productRepo = new Repository<Product>();
const product = productRepo.create({
name: 'Laptop',
price: 999,
inStock: true
});
const updated = productRepo.update(product.id, { price: 899 });
2. API Client with Type Safety
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
interface RequestConfig<TBody = unknown> {
method: HttpMethod;
url: string;
body?: TBody;
headers?: Record<string, string>;
}
interface ApiResponse<T> {
data: T;
status: number;
headers: Headers;
}
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async request<TResponse, TBody = unknown>(
config: RequestConfig<TBody>
): Promise<ApiResponse<TResponse>> {
const response = await fetch(`${this.baseUrl}${config.url}`, {
method: config.method,
headers: {
'Content-Type': 'application/json',
...config.headers
},
body: config.body ? JSON.stringify(config.body) : undefined
});
const data = await response.json();
return {
data,
status: response.status,
headers: response.headers
};
}
// Convenience methods
get<T>(url: string) {
return this.request<T>({ method: 'GET', url });
}
post<T, B>(url: string, body: B) {
return this.request<T, B>({ method: 'POST', url, body });
}
put<T, B>(url: string, body: B) {
return this.request<T, B>({ method: 'PUT', url, body });
}
patch<T, B>(url: string, body: B) {
return this.request<T, B>({ method: 'PATCH', url, body });
}
delete<T>(url: string) {
return this.request<T>({ method: 'DELETE', url });
}
}
// Usage
interface User {
id: string;
email: string;
name: string;
}
interface CreateUserDto {
email: string;
name: string;
}
const api = new ApiClient('https://api.example.com');
// Fully typed API calls
const { data: users } = await api.get<User[]>('/users');
const { data: newUser } = await api.post<User, CreateUserDto>('/users', {
email: '[email protected]',
name: 'Alice'
});
const { data: updated } = await api.patch<User, Partial<User>>(
`/users/${newUser.id}`,
{ name: 'Alice Smith' }
);
3. Event Emitter with Type Safety
type EventMap = Record<string, any>;
type EventKey<T extends EventMap> = string & keyof T;
type EventHandler<T> = (payload: T) => void;
class TypedEventEmitter<Events extends EventMap> {
private handlers: {
[K in keyof Events]?: EventHandler<Events[K]>[]
} = {};
on<K extends EventKey<Events>>(
event: K,
handler: EventHandler<Events[K]>
): () => void {
if (!this.handlers[event]) {
this.handlers[event] = [];
}
this.handlers[event]!.push(handler);
// Return unsubscribe function
return () => this.off(event, handler);
}
off<K extends EventKey<Events>>(
event: K,
handler: EventHandler<Events[K]>
): void {
const handlers = this.handlers[event];
if (handlers) {
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
}
}
emit<K extends EventKey<Events>>(event: K, payload: Events[K]): void {
const handlers = this.handlers[event];
if (handlers) {
handlers.forEach(h => h(payload));
}
}
}
// Usage
interface MyEvents {
'user:login': { userId: string; timestamp: Date };
'user:logout': { userId: string };
'data:update': { table: string; records: number };
'error': { message: string; code: number };
}
const emitter = new TypedEventEmitter<MyEvents>();
// Type-safe event handling
emitter.on('user:login', ({ userId, timestamp }) => {
console.log(`User ${userId} logged in at ${timestamp}`);
});
emitter.on('data:update', ({ table, records }) => {
console.log(`${table} updated with ${records} records`);
});
// Type error: wrong payload shape
// emitter.emit('user:login', { userId: '1' }); // Error: missing timestamp
// Correct usage
emitter.emit('user:login', {
userId: 'user-123',
timestamp: new Date()
});
Advanced Patterns
4. Constrained Generics
// Only accept types with an 'id' property
interface HasId {
id: string;
}
function findById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
// Multiple constraints
interface HasTimestamps {
createdAt: Date;
updatedAt: Date;
}
function sortByDate<T extends HasTimestamps>(items: T[]): T[] {
return [...items].sort((a, b) =>
b.createdAt.getTime() - a.createdAt.getTime()
);
}
// Keyof constraints
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: '1', name: 'Alice', age: 30 };
const name = getProperty(user, 'name'); // Type: string
const age = getProperty(user, 'age'); // Type: number
// const wrong = getProperty(user, 'email'); // Error: 'email' doesn't exist
5. Conditional Types
// Basic conditional type
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<123>; // false
// Extract type from array
type ElementType<T> = T extends (infer E)[] ? E : T;
type Numbers = ElementType<number[]>; // number
type StringOrNum = ElementType<string>; // string
// Flatten nested arrays
type Flatten<T> = T extends (infer E)[] ? Flatten<E> : T;
type DeepArray = Flatten<string[][][]>; // string
// Create nullable version
type Nullable<T> = T | null;
type NullableString = Nullable<string>; // string | null
// Non-null type
type NonNullable<T> = T extends null | undefined ? never : T;
type DefinitelyString = NonNullable<string | null>; // string
6. Mapped Types
// Make all properties optional
type Partial<T> = {
[P in keyof T]?: T[P];
};
// Make all properties required
type Required<T> = {
[P in keyof T]-?: T[P];
};
// Make all properties readonly
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
// Remove readonly
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
// Pick specific properties
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
// Omit specific properties
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
// Record type
type Record<K extends keyof any, T> = {
[P in K]: T;
};
// Usage examples
interface User {
id: string;
email: string;
name: string;
password: string;
createdAt: Date;
}
// API response doesn't include password
type UserResponse = Omit<User, 'password'>;
// Form input only needs name and email
type UserFormInput = Pick<User, 'name' | 'email'>;
// Update can have partial fields
type UserUpdate = Partial<Omit<User, 'id' | 'createdAt'>>;
// Readonly for immutable state
type ImmutableUser = Readonly<User>;
7. Template Literal Types
// Create event names from actions
type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickEvent = EventName<'click'>; // 'onClick'
type HoverEvent = EventName<'hover'>; // 'onHover'
// CSS property types
type CSSProperty = 'margin' | 'padding' | 'border';
type CSSDirection = 'top' | 'right' | 'bottom' | 'left';
type CSSPropertyWithDirection = `${CSSProperty}${Capitalize<CSSDirection>}`;
// 'marginTop' | 'marginRight' | 'marginBottom' | 'marginLeft'
// 'paddingTop' | 'paddingRight' | ... etc
// Route parameters
type Route<Path extends string> =
Path extends `${infer Start}/:${infer Param}/${infer Rest}`
? { [K in Param]: string } & Route<`${Start}/${Rest}`>
: Path extends `${string}/:${infer Param}`
? { [K in Param]: string }
: {};
// Usage
type UserRoute = Route<'/users/:userId/posts/:postId'>;
// { userId: string; postId: string }
8. Infer with Generics
// Extract return type
type ReturnType<T extends (...args: any[]) => any> =
T extends (...args: any[]) => infer R ? R : never;
function createUser() {
return { id: '1', name: 'Alice' };
}
type User = ReturnType<typeof createUser>;
// { id: string; name: string }
// Extract parameters
type Parameters<T extends (...args: any[]) => any> =
T extends (...args: infer P) => any ? P : never;
function updateUser(id: string, data: Partial<User>) {
return { ...data, id };
}
type UpdateUserParams = Parameters<typeof updateUser>;
// [string, Partial<User>]
// Extract Promise type
type Awaited<T> = T extends Promise<infer U> ? U : T;
async function fetchUser(): Promise<User> {
return { id: '1', name: 'Alice' };
}
type FetchedUser = Awaited<ReturnType<typeof fetchUser>>;
// User (not Promise<User>)
Utility Types Reference
| Type | Purpose | Example |
|---|---|---|
Partial<T> | All properties optional | Partial<User> |
Required<T> | All properties required | Required<Config> |
Readonly<T> | All properties readonly | Readonly<State> |
Pick<T, K> | Select specific keys | Pick<User, 'id' | 'name'> |
Omit<T, K> | Remove specific keys | Omit<User, 'password'> |
Record<K, V> | Dictionary type | Record<string, User> |
Exclude<T, U> | Remove types from union | Exclude<'a' | 'b', 'a'> |
Extract<T, U> | Extract types from union | Extract<'a' | 'b', 'a'> |
NonNullable<T> | Remove null/undefined | NonNullable<string | null> |
ReturnType<T> | Function return type | ReturnType<typeof fn> |
Parameters<T> | Function parameters | Parameters<typeof fn> |
Awaited<T> | Unwrap Promise | Awaited<Promise<User>> |
Common Patterns
Factory Pattern
interface EntityConstructor<T extends HasId> {
new (data: Omit<T, 'id'>): T;
}
function createEntity<T extends HasId>(
Constructor: EntityConstructor<T>,
data: Omit<T, 'id'>
): T {
return new Constructor(data);
}
class Product implements HasId {
id: string;
name: string;
price: number;
constructor(data: Omit<Product, 'id'>) {
this.id = crypto.randomUUID();
this.name = data.name;
this.price = data.price;
}
}
const product = createEntity(Product, { name: 'Laptop', price: 999 });
Builder Pattern
class QueryBuilder<T extends Record<string, any>> {
private filters: Partial<T> = {};
private sortKey: keyof T | null = null;
private sortDirection: 'asc' | 'desc' = 'asc';
where<K extends keyof T>(key: K, value: T[K]): this {
this.filters[key] = value;
return this;
}
orderBy(key: keyof T, direction: 'asc' | 'desc' = 'asc'): this {
this.sortKey = key;
this.sortDirection = direction;
return this;
}
build(): { filters: Partial<T>; sort: { key: keyof T; direction: 'asc' | 'desc' } | null } {
return {
filters: this.filters,
sort: this.sortKey ? { key: this.sortKey, direction: this.sortDirection } : null
};
}
}
interface User {
id: string;
name: string;
age: number;
active: boolean;
}
const query = new QueryBuilder<User>()
.where('active', true)
.where('age', 25)
.orderBy('name', 'asc')
.build();
Summary
- Generics add flexibility while preserving type safety
- Constraints (
extends) ensure types have required properties - Conditional types create type-level logic
- Mapped types transform object shapes
- Utility types solve common transformation needs
- Template literals create type-safe string patterns
Master these patterns and you’ll write TypeScript that’s both flexible and bulletproof.
What to Read Next
- TypeScript Cheat Sheet — Quick reference for all TypeScript syntax
- React with TypeScript — Type-safe React patterns
- Node.js with TypeScript — Backend TypeScript setup