After years of writing TypeScript professionally, certain patterns have become muscle memory. These are not exotic type gymnastics you pull out to impress at a conference. They are practical, everyday patterns that catch real bugs, improve developer experience, and make refactoring feel safe instead of terrifying.
Every pattern here follows the same structure: here is the problem, here is how the pattern solves it, and here is why you should care.
1. Branded Types (Nominal Typing)
TypeScript uses structural typing, which means two types with the same shape are interchangeable. That is usually great, until it is a source of subtle bugs.
The problem: You have functions that accept a string for a user ID and a string for an order ID. Nothing prevents you from passing one where the other is expected.
function getUser(id: string) { /* fetches user */ }
function getOrder(id: string) { /* fetches order */ }
const orderId = 'ord_456';
getUser(orderId); // No error. Wrong data. Silent bug.
The pattern:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type Currency = Brand<number, 'Currency'>;
function UserId(id: string): UserId { return id as UserId; }
function OrderId(id: string): OrderId { return id as OrderId; }
function Currency(cents: number): Currency { return cents as Currency; }
function getUser(id: UserId) { /* ... */ }
function getOrder(id: OrderId) { /* ... */ }
const userId = UserId('usr_123');
const orderId = OrderId('ord_456');
getUser(userId); // Compiles
getUser(orderId); // Error: Argument of type 'OrderId' is not
// assignable to parameter of type 'UserId'
charge(Currency(1999)); // Compiles
charge(1999); // Error — raw number is not Currency
The Brand utility type adds a phantom property that only exists at the type level. At runtime, these are still plain strings and numbers with zero overhead. I use this everywhere: database IDs, monetary values, validated email addresses, sanitized HTML strings.
2. Exhaustive Switch with never
The problem: You add a new variant to a union type, but forget to handle it in three of the twelve switch statements scattered across your codebase. The code compiles. The bug ships.
type PaymentStatus =
| 'pending' | 'processing' | 'completed' | 'failed' | 'refunded';
function assertNever(value: never, msg?: string): never {
throw new Error(msg ?? `Unhandled: ${JSON.stringify(value)}`);
}
function getStatusColor(status: PaymentStatus): string {
switch (status) {
case 'pending': return '#f59e0b';
case 'processing': return '#3b82f6';
case 'completed': return '#10b981';
case 'failed': return '#ef4444';
case 'refunded': return '#8b5cf6';
default: return assertNever(status);
}
}
The moment someone adds 'disputed' to PaymentStatus, every switch without a case 'disputed' branch will fail to compile. This single pattern has prevented more production bugs than any linting rule.
3. Discriminated Unions
The problem: You model API responses with optional fields and boolean flags. You end up with impossible states: { loading: false, error: undefined, data: undefined }.
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error; retryCount: number };
function renderUserProfile(state: AsyncState<User>) {
switch (state.status) {
case 'idle':
return null;
case 'loading':
return <Spinner />;
case 'success':
return <Profile user={state.data} />;
case 'error':
return <ErrorBanner
message={state.error.message}
retries={state.retryCount}
/>;
default:
return assertNever(state);
}
}
The status field is the discriminant — TypeScript uses it to narrow the type within each branch. You cannot access state.data in the 'loading' branch because it does not exist there. Impossible states become impossible to represent.
4. The Type-Safe Builder Pattern
The problem: You need to construct complex objects where certain fields are required and the order of operations matters. A plain constructor with eight parameters is unreadable.
interface QueryConfig<T> {
table: string;
filters: Partial<T>;
orderBy?: { field: keyof T; direction: 'asc' | 'desc' };
limit: number;
offset: number;
}
class QueryBuilder<T extends Record<string, unknown>> {
private config: Partial<QueryConfig<T>> = {
filters: {}, limit: 50, offset: 0
};
from(table: string): this {
this.config.table = table;
return this;
}
where<K extends keyof T>(field: K, value: T[K]): this {
this.config.filters = {
...this.config.filters, [field]: value
};
return this;
}
orderBy(field: keyof T, direction: 'asc' | 'desc' = 'asc'): this {
this.config.orderBy = { field, direction };
return this;
}
limit(n: number): this {
this.config.limit = n;
return this;
}
build(): QueryConfig<T> {
if (!this.config.table) throw new Error('Table is required');
return this.config as QueryConfig<T>;
}
}
interface User {
name: string;
email: string;
role: string;
createdAt: Date;
}
const query = new QueryBuilder<User>()
.from('users')
.where('role', 'admin')
.where('name', 'Alice')
.orderBy('createdAt', 'desc')
.limit(10)
.build();
The generic constraint <K extends keyof T> on the where method ensures field names are validated against the type and values match the expected type for that field.
5. Type-Safe Event Emitters
The problem: Traditional event emitters use string event names and any payloads. You emit 'userCreated' with the wrong shape, or listen for 'userCreate' (typo), and nothing catches it.
type EventMap = {
userCreated: { id: string; email: string };
orderPlaced: { orderId: string; total: number };
error: { code: number; message: string };
};
class TypedEmitter<T extends Record<string, unknown>> {
private listeners = new Map<
keyof T, Set<(payload: never) => void>
>();
on<K extends keyof T>(
event: K,
handler: (payload: T[K]) => void
): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
const handlers = this.listeners.get(event)!;
handlers.add(handler as (payload: never) => void);
return () => {
handlers.delete(handler as (payload: never) => void);
};
}
emit<K extends keyof T>(event: K, payload: T[K]): void {
const handlers = this.listeners.get(event);
if (handlers) {
handlers.forEach((fn) =>
(fn as (payload: T[K]) => void)(payload)
);
}
}
}
const bus = new TypedEmitter<EventMap>();
bus.on('userCreated', (payload) => {
console.log(payload.email); // fully typed
});
bus.emit('userCreated', { id: '1', email: 'a@b.com' }); // OK
bus.emit('userCreated', { id: '1' }); // Error: missing 'email'
bus.emit('userCreatd', { id: '1', email: '' }); // Error: typo caught
The EventMap type is the single source of truth. Add a new event there, and both emitters and listeners get type-checked automatically.
6. Const Assertions for Immutable Configs
The problem: You define a configuration object and TypeScript widens the types. Your status: 'active' becomes status: string, losing the literal type information.
// Without 'as const' — types are widened
const configWide = {
api: { baseUrl: 'https://api.example.com', timeout: 5000 },
retries: 3,
};
// configWide.retries is number
// With 'as const' — types are narrow and immutable
const config = {
api: { baseUrl: 'https://api.example.com', timeout: 5000 },
retries: 3,
features: ['dark-mode', 'notifications'],
} as const;
// config.retries is 3
// config.features is readonly ['dark-mode', 'notifications']
// Derive types from the value — no duplication
type Feature = typeof config.features[number];
// 'dark-mode' | 'notifications'
const ROUTES = {
home: '/',
blog: '/blog',
blogPost: '/blog/:slug',
settings: '/settings',
} as const;
type Route = typeof ROUTES[keyof typeof ROUTES];
// '/' | '/blog' | '/blog/:slug' | '/settings'
Your runtime values become the source of truth for your types. You define data once and derive types from it, rather than maintaining parallel type definitions that drift out of sync.
7. Template Literal Types
The problem: You build string identifiers by concatenating strings, but the resulting type is just string. You lose all type safety at the boundary.
type CSSVar<T extends string> = `--${T}`;
type ThemeColor = 'primary' | 'secondary' | 'accent';
type ThemeVar = CSSVar<ThemeColor>;
// '--primary' | '--secondary' | '--accent'
function setCSSVar(name: ThemeVar, value: string) {
document.documentElement.style.setProperty(name, value);
}
setCSSVar('--primary', '#3b82f6'); // Compiles
setCSSVar('--unknown', '#000'); // Error
// Type-safe route builder
type ApiVersion = 'v1' | 'v2';
type Resource = 'users' | 'orders' | 'products';
type ApiRoute = `/api/${ApiVersion}/${Resource}`;
fetchResource('/api/v1/users'); // Compiles
fetchResource('/api/v3/users'); // Error
// Extract params from route patterns
type ExtractParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<Rest>
: T extends `${string}:${infer Param}`
? Param
: never;
type BlogParams = ExtractParams<'/blog/:slug'>;
// 'slug'
type OrderParams = ExtractParams<'/orders/:id/items/:itemId'>;
// 'id' | 'itemId'
Template literal types turn string manipulation into a type-level operation. The ExtractParams type above is a real utility I carry between projects.
8. Conditional Types for API Responses
The problem: Your API endpoints return different shapes depending on query parameters. With a single return type, consumers must narrow manually.
interface UserSummary { id: string; name: string }
interface UserDetail extends UserSummary {
email: string;
role: string;
createdAt: string;
preferences: Record<string, unknown>;
}
interface FetchOptions {
detailed?: boolean;
includeMetadata?: boolean;
}
type UserResponse<T extends FetchOptions> =
T extends { detailed: true }
? T extends { includeMetadata: true }
? { data: UserDetail; meta: { fetchedAt: string } }
: UserDetail
: UserSummary;
async function fetchUser<T extends FetchOptions>(
id: string,
options: T
): Promise<UserResponse<T>> {
const params = new URLSearchParams();
if (options.detailed) params.set('detailed', 'true');
if (options.includeMetadata) params.set('meta', 'true');
const res = await fetch(`/api/users/${id}?${params}`);
return res.json();
}
const summary = await fetchUser('usr_1', { detailed: false });
// Type: UserSummary
const detail = await fetchUser('usr_1', { detailed: true });
// Type: UserDetail
const withMeta = await fetchUser('usr_1', {
detailed: true,
includeMetadata: true,
});
// Type: { data: UserDetail; meta: { fetchedAt: string } }
The caller's options determine the return type at compile time. No type assertions, no runtime checks. The type system encodes the API contract directly.
Bringing It All Together
None of these patterns exist in isolation. In a real project, you combine them:
type UserId = Brand<string, 'UserId'>;
type UserEvent =
| { type: 'created'; userId: UserId; email: string }
| { type: 'updated'; userId: UserId; fields: string[] }
| { type: 'deleted'; userId: UserId; reason: string };
function handleUserEvent(event: UserEvent): void {
switch (event.type) {
case 'created':
sendWelcomeEmail(event.email);
break;
case 'updated':
invalidateCache(event.userId, event.fields);
break;
case 'deleted':
auditLog(event.userId, event.reason);
break;
default:
assertNever(event);
}
}
The real value is the compound effect of layering them together. Branded types ensure IDs don't get mixed up. Discriminated unions ensure state is always valid. Exhaustive switches ensure every case is handled. Template literal types ensure string identifiers are correct. Conditional types ensure API contracts are encoded in the type system.
The result is code where entire categories of bugs are structurally impossible. Not caught by tests. Not caught by linters. Impossible to write in the first place. That is the promise of TypeScript done well.