TypeScript Patterns That Actually Pay Off in Large Projects
TypeScript's type system is powerful enough to encode complex domain invariants, but that power comes with a cost: types can become a maintenance burden if the complexity doesn't pull its weight. After working on several mid-to-large enterprise codebases, these are the patterns that consistently deliver value without becoming obstacles.
Discriminated Unions for State Machines
If a value can be in one of several distinct states, model it explicitly rather than using a combination of optional fields:
// Avoid: ambiguous optional fields
interface FetchState {
loading: boolean;
data?: User;
error?: string;
}
// Prefer: discriminated union
type FetchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; message: string };
The compiler now exhausts all cases for you:
function renderState(state: FetchState) {
switch (state.status) {
case 'idle': return 'Not started';
case 'loading': return 'Loading...';
case 'success': return state.data.name; // data is guaranteed non-null here
case 'error': return `Error: ${state.message}`;
}
// TypeScript will error if you add a new case without handling it
}
This pays off most in long-lived codebases where new cases get added over time — the compiler flags every call site that needs updating.
Branded Types for Value Objects
Primitive obsession is a real problem in business logic. Using string for both email addresses and usernames means the type checker can't stop you from passing one where the other is expected:
// Nothing stops this:
function sendWelcomeEmail(email: string, userId: string) { ... }
sendWelcomeEmail(userId, email); // swapped — no error
// Branded types fix this:
type Email = string & { readonly _brand: 'Email' };
type UserId = string & { readonly _brand: 'UserId' };
function createEmail(raw: string): Email {
if (!raw.includes('@')) throw new Error('Invalid email');
return raw as Email;
}
function sendWelcomeEmail(email: Email, userId: UserId) { ... }
// sendWelcomeEmail(userId, email); // TypeScript error
The branded type has zero runtime cost — it's purely a compile-time constraint.
Template Literal Types for Event Systems
When building event-driven architectures, template literal types can give you type-safe event names:
type EntityEvent<T extends string, A extends string> = `${T}:${A}`;
type UserEvent = EntityEvent<'user', 'created' | 'updated' | 'deleted'>;
// = 'user:created' | 'user:updated' | 'user:deleted'
type OrderEvent = EntityEvent<'order', 'placed' | 'fulfilled' | 'cancelled'>;
type DomainEvent = UserEvent | OrderEvent;
interface EventBus {
on<E extends DomainEvent>(event: E, handler: (payload: EventPayload<E>) => void): void;
emit<E extends DomainEvent>(event: E, payload: EventPayload<E>): void;
}
This is especially useful in microservice or message-driven systems where event names need to be both human-readable strings and statically verified.
Infer for Type Extraction Utilities
The infer keyword unlocks powerful utility types that keep type definitions DRY:
// Extract the resolved value type from a Promise
type Awaited<T> = T extends Promise<infer R> ? R : T;
// Extract the element type from an array
type ElementOf<T extends readonly unknown[]> = T extends readonly (infer E)[] ? E : never;
// Extract the argument types from a function
type FirstArg<T extends (...args: any[]) => any> =
T extends (first: infer A, ...rest: any[]) => any ? A : never;
// Practical example: derive a config type from a builder
const dbConfig = {
host: 'localhost',
port: 5432,
database: 'verixon',
} as const;
type DbConfig = typeof dbConfig;
// { readonly host: "localhost"; readonly port: 5432; readonly database: "verixon" }
Satisfies Operator for Validated Literals
The satisfies operator (TypeScript 4.9+) lets you validate a value against a type while keeping the most specific inferred type:
type Route = {
path: string;
component: string;
auth: boolean;
};
const routes = {
home: { path: '/', component: 'HomeComponent', auth: false },
profile: { path: '/me', component: 'ProfileComponent', auth: true },
admin: { path: '/admin', component: 'AdminComponent', auth: true },
} satisfies Record<string, Route>;
// routes.home.path is typed as "/" (literal), not string
// TypeScript would error if any route was missing required fields
This is particularly useful for route configurations, theme tokens, and any case where you want both validation and precise inference.
What to Avoid
A few patterns that look clever but create maintenance debt:
- Deeply nested conditional types — if you need more than 2–3 levels, split into named intermediate types
- Overusing
any— even once as a "temporary" fix tends to stay and erode nearby types - Phantom type parameters unused at runtime — fine in small doses, confusing in APIs others use
- Type assertions (
as) in business logic — a sign that your runtime values and types have drifted
The goal isn't maximal type expressiveness. It's catching real bugs early and making refactoring safe. Measure your type complexity against that yardstick.
Have thoughts on this? Reach out directly.
Discuss this article