D
The Dev Blog
typescript web-dev architecture

TypeScript Patterns That Scale: Lessons from Large Codebases

M
Mohan
· · 7 min read
Share
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.

M

Mohan

Software engineer writing about AI, distributed systems, and the craft of building great software.

Share

Stay up to date

Get notified when I publish new articles. No spam, unsubscribe anytime.

No spam. Unsubscribe anytime.