TypeScript Patterns for Large Codebases
Advanced TypeScript patterns I use daily: discriminated unions, branded types, the builder pattern and utility types that keep large codebases safe.

Beyond Basic Types
TypeScript's real power shows in large codebases where type safety prevents entire classes of bugs. Here are the patterns I reach for most often.
Discriminated Unions for State Machines
Instead of optional fields and boolean flags, model states explicitly:
// ❌ Boolean soup — easy to create invalid states
interface Request {
data?: Response;
error?: Error;
isLoading: boolean;
isError: boolean;
}
// ✅ Discriminated union — each state is explicit
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function renderUser(state: AsyncState<User>) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
return <UserCard user={state.data} />;
case "error":
return <ErrorMessage error={state.error} />;
}
}The compiler guarantees you handle every case. Adding a new state? TypeScript tells you every place that needs updating.
Branded Types for Domain Safety
Primitive types don't prevent mixing up values that share a type:
// ❌ Both are strings — easy to swap by accident
function transferMoney(fromAccountId: string, toAccountId: string) {}
transferMoney(toId, fromId); // Bug! No compiler error.
// ✅ Branded types — compiler catches the swap
type AccountId = string & { readonly __brand: "AccountId" };
type TransactionId = string & { readonly __brand: "TransactionId" };
function accountId(id: string): AccountId {
return id as AccountId;
}
function transfer(from: AccountId, to: AccountId) {}
transfer(txnId, accountId("123")); // ✅ Compiler error!I use branded types for IDs, currencies, emails, and any domain value where mixing up primitives would cause real bugs.
The Builder Pattern for Complex Objects
When constructing objects with many optional fields, builders provide a fluent API with compile-time validation:
class QueryBuilder<T extends Record<string, unknown>> {
private query: Partial<T> = {};
where<K extends keyof T>(key: K, value: T[K]): this {
this.query[key] = value;
return this;
}
orderBy(field: keyof T, direction: "asc" | "desc" = "asc"): this {
// Store ordering config
return this;
}
limit(n: number): this {
// Store limit
return this;
}
build(): T {
return this.query as T;
}
}
// Usage — fully type-safe
const query = new QueryBuilder<User>()
.where("role", "admin")
.where("active", true)
.orderBy("createdAt", "desc")
.limit(10)
.build();Utility Types You Should Know
TypeScript's built-in utility types are powerful. Here are the ones I use most:
// Extract only the keys whose values match a type
type KeysOfType<T, V> = {
[K in keyof T]: T[K] extends V ? K : never;
}[keyof T];
type User = {
id: number;
name: string;
email: string;
age: number;
};
type StringKeys = KeysOfType<User, string>; // "name" | "email"
// Make specific keys required
type RequireKeys<T, K extends keyof T> = T & Required<Pick<T, K>>;
type Config = {
host?: string;
port?: number;
debug?: boolean;
};
type ProductionConfig = RequireKeys<Config, "host" | "port">;
// { host: string; port: number; debug?: boolean }
// Deep readonly — prevents mutation at any depth
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};satisfies for Const Assertions
The satisfies operator gives you the best of both worlds — type checking and narrow inference:
const ROUTES = {
home: "/",
blog: "/blog",
about: "/about",
contact: "/contact",
} as const satisfies Record<string, string>;
// Type is narrowed to the literal values
type Route = (typeof ROUTES)[keyof typeof ROUTES];
// "/" | "/blog" | "/about" | "/contact"
// Typo in the value? Compiler catches it.
// Missing a required key? Compiler catches it.Type-Safe Event Emitters
Combine generics with mapped types for fully typed events:
type EventMap = {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"order:created": { orderId: string; total: number };
};
class TypedEmitter<T extends Record<string, unknown>> {
private handlers = new Map<keyof T, Set<(data: never) => void>>();
on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler as (data: never) => void);
}
emit<K extends keyof T>(event: K, data: T[K]): void {
this.handlers.get(event)?.forEach((handler) => handler(data as never));
}
}
const emitter = new TypedEmitter<EventMap>();
emitter.on("user:login", (data) => {
console.log(data.userId); // ✅ Typed
console.log(data.timestamp); // ✅ Typed
});
emitter.emit("order:created", {
orderId: "123",
total: 99.99,
// extra: true — ✅ Compiler error!
});Key Takeaways
- Model states, not flags — Discriminated unions eliminate impossible states
- Brand your primitives — Don't let
stringmean everything - Leverage
satisfies— Get both type safety and literal inference - Build utility types — Small type helpers compound into massive safety gains
- Let the compiler work for you — If a bug can be caught at compile time, it should be
TypeScript is at its best when you lean into the type system rather than fighting it with any and type assertions. The upfront investment pays dividends in every refactor, every code review, and every production deployment.


