TypeScript's type system is incredibly powerful, but most codebases only scratch the surface. Here are the patterns I reach for every single day.
Discriminated Unions for State
Instead of using boolean flags to represent state, use discriminated unions:
// Instead of this
type Request = {
isLoading: boolean;
isError: boolean;
data?: User;
error?: Error;
};
// Use this
type Request =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: Error };The discriminated union makes impossible states impossible. You can't accidentally access data when the request is still loading.
Branded Types for Type Safety
Primitive types like string and number lose meaning in large codebases. Branded types add semantic meaning:
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function createUserId(id: string): UserId {
return id as UserId;
}
function getUser(id: UserId): Promise<User> {
// Now you can't accidentally pass an OrderId here
}The satisfies Operator
The satisfies operator (TypeScript 4.9+) lets you validate types while preserving the narrowest type:
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
} satisfies Record<string, string | number>;
// config.apiUrl is still typed as string, not string | numberType-Safe Event Emitters
Using generics to create type-safe event systems:
type EventMap = {
'user:login': { userId: string; timestamp: number };
'user:logout': { userId: string };
'error': { message: string; code: number };
};
class TypedEmitter<T extends Record<string, unknown>> {
private handlers = new Map<keyof T, Set<Function>>();
on<K extends keyof T>(event: K, handler: (payload: T[K]) => void) {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
}
emit<K extends keyof T>(event: K, payload: T[K]) {
this.handlers.get(event)?.forEach(handler => handler(payload));
}
}
const emitter = new TypedEmitter<EventMap>();
emitter.on('user:login', ({ userId, timestamp }) => {
// Fully typed!
});Const Assertions for Literal Types
Use as const to preserve literal types in arrays and objects:
const ROLES = ['admin', 'editor', 'viewer'] as const;
type Role = typeof ROLES[number]; // 'admin' | 'editor' | 'viewer'Template Literal Types
Create complex string types using template literals:
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type APIRoute = `/api/${string}`;
type Endpoint = `${HTTPMethod} ${APIRoute}`;
// Valid: "GET /api/users"
// Invalid: "PATCH /api/users"Conclusion
These patterns have saved me countless hours of debugging. The key insight is that TypeScript's type system can encode business rules, making bugs literally impossible to write. Invest time in learning these patterns — your future self will thank you.