TypeScript Patterns That Scale: Lessons from Large Codebases
TypeScript at scale is a different game than TypeScript in a tutorial. Here are patterns I’ve found genuinely useful in large production codebases.
Discriminated Unions for State Machines
Instead of modeling state with booleans and optional fields, use discriminated unions:
// Bad: unclear which combinations are valid
type Request = {
status: string;
data?: ResponseData;
error?: Error;
isLoading: boolean;
};
// Good: each state is explicit
type Request =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: ResponseData }
| { status: 'error'; error: Error };
Now TypeScript enforces that you can only access data when status === 'success'. Impossible states become unrepresentable.
Branded Types for Semantic Safety
Primitive types don’t carry meaning. A string userId looks the same as a string email to the type checker:
type UserId = string & { readonly __brand: 'UserId' };
type Email = string & { readonly __brand: 'Email' };
function createUserId(id: string): UserId {
return id as UserId;
}
function sendEmail(to: Email, subject: string) { /* ... */ }
const userId = createUserId('usr_123');
sendEmail(userId, 'Hello'); // Type error!
This catches a class of bugs at compile time that would otherwise only surface in production.
The Builder Pattern for Complex Objects
When constructing objects with many optional fields, builders are cleaner than giant parameter lists:
class QueryBuilder<T> {
private filters: Filter[] = [];
private sortBy?: string;
private limitValue?: number;
where(field: keyof T, op: Operator, value: unknown) {
this.filters.push({ field: field as string, op, value });
return this;
}
sort(field: keyof T) {
this.sortBy = field as string;
return this;
}
limit(n: number) {
this.limitValue = n;
return this;
}
build(): Query {
return { filters: this.filters, sort: this.sortBy, limit: this.limitValue };
}
}
// Usage
const query = new QueryBuilder<User>()
.where('age', '>', 18)
.sort('createdAt')
.limit(10)
.build();
satisfies for Type Checking Without Widening
The satisfies operator lets you validate that a value matches a type without losing its literal type information:
const config = {
port: 3000,
host: 'localhost',
debug: true,
} satisfies Record<string, string | number | boolean>;
// config.port is still number (not string | number | boolean)
This is invaluable for configuration objects and route definitions.
Exhaustive Matching
Ensure switch statements handle every case:
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
function handleStatus(status: Request['status']) {
switch (status) {
case 'idle': return null;
case 'loading': return <Spinner />;
case 'success': return <Data />;
case 'error': return <Error />;
default: return assertNever(status); // Compile error if a case is missing
}
}
When you add a new status variant, TypeScript will flag every switch that doesn’t handle it.
The Takeaway
These patterns share a common theme: make invalid states impossible to represent. The more invariants you encode in the type system, the fewer bugs make it to runtime. TypeScript’s type system is powerful enough to enforce real business logic - use it.
Mohan
Software engineer writing about AI, distributed systems, and the craft of building great software.
Related Articles
React Server Components: What They Actually Change
RSCs aren't just SSR. They fundamentally change how React apps are architected - here's what matters for real projects.
Modern AI Agent Architectures: How Multi-Agent Systems Like OpenHands and Claude Flow Work
A deep dive into multi-agent AI architectures - how frameworks like OpenHands and Claude Flow use planner-worker patterns, role-based orchestration, and parallel execution to solve complex tasks.
Why I Chose Astro for My Blog (And You Should Too)
Astro ships zero JavaScript by default, supports Markdown natively, and generates blazing-fast static sites. Here's why it's the best choice for content-heavy sites.
Stay up to date
Get notified when I publish new articles. No spam, unsubscribe anytime.
No spam. Unsubscribe anytime.