← All articles

TypeScript in Large Codebases: Branded Types, Infer, Module Augmentation, and tRPC-Style Safety

TypeScript is easy to use and hard to use well. In a large codebase the gap between those two states is measured in production bugs. Here are the patterns that actually prevent them.

Our codebase has 280,000 lines of TypeScript across a monorepo. We have 12 engineers touching it concurrently. Without deliberate patterns, TypeScript's type system becomes a false sense of security — full of any escapes, unchecked API responses, and types that describe intentions rather than constraints.

Here is the toolkit we use to keep the types meaningful at scale.

Branded Types: When string Is Not Just a String

TypeScript's structural type system means that two string values are interchangeable. This is frequently wrong at the domain level — a UserId and a TenantId are both strings, but you should never pass one where the other is expected.

Branded types enforce this at compile time:

// types/branded.ts

declare const __brand: unique symbol;
type Brand<T, B> = T & { [__brand]: B };

export type UserId   = Brand<string, 'UserId'>;
export type TenantId = Brand<string, 'TenantId'>;
export type SessionToken = Brand<string, 'SessionToken'>;

// Constructor functions — the only way to create branded values
export const UserId   = (id: string): UserId   => id as UserId;
export const TenantId = (id: string): TenantId => id as TenantId;

// Now this is a compile error — not a runtime surprise:
function getUser(id: UserId) { /* ... */ }

const tenantId = TenantId('acme');
getUser(tenantId); // Error: Argument of type 'TenantId' is not
                   // assignable to parameter of type 'UserId'

We brand every domain identifier: user IDs, tenant IDs, session tokens, external reference IDs. The upfront cost is small. The payoff is that an entire category of ID-confusion bugs becomes impossible.

Tip Combine branded types with zod to validate at the system boundary. The branded type enforces structural safety in TypeScript; zod enforces it at runtime when data arrives from an API.
import { z } from 'zod';
import { UserId } from '@/types/branded';

const UserSchema = z.object({
  id:    z.string().transform(UserId),  // string -> UserId
  email: z.string().email(),
  name:  z.string(),
});

type User = z.infer<typeof UserSchema>;
// User.id is now typed as UserId, not string

Infer With Const Assertions: Extracting Literal Types

TypeScript's infer keyword, combined with as const, lets you extract precise literal types from data structures — without duplicating type definitions.

// Define the source of truth as a const — not a type
const PLAN_TIERS = ['free', 'pro', 'enterprise'] as const;
const ROLE_PERMISSIONS = {
  admin:  ['read', 'write', 'delete', 'manage'] as const,
  editor: ['read', 'write'] as const,
  viewer: ['read'] as const,
} as const;

// Extract types from the data
type PlanTier = typeof PLAN_TIERS[number];
// = 'free' | 'pro' | 'enterprise'

type Role = keyof typeof ROLE_PERMISSIONS;
// = 'admin' | 'editor' | 'viewer'

type PermissionsFor<R extends Role> = typeof ROLE_PERMISSIONS[R][number];
// PermissionsFor<'editor'> = 'read' | 'write'

// Utility: extract return type of async function
type Awaited<T> = T extends Promise<infer R> ? R : T;

type FetchUserResult = Awaited<ReturnType<typeof fetchUser>>;
// Automatically stays in sync with fetchUser's return type

The key principle: types should be derived from code, not duplicated alongside it. Every time you write a type that mirrors a data structure, you create a synchronization burden. Let infer and typeof do the work.

Module Augmentation for Third-Party Libraries

When a third-party library has incomplete or incorrect types, the instinct is to reach for as any. Module augmentation lets you fix the types properly without forking the library:

// types/augmentations.d.ts

// Example: extending Express Request with our auth context
import 'express';

declare module 'express' {
  interface Request {
    user?: {
      id: UserId;
      tenantId: TenantId;
      role: Role;
    };
  }
}

// Example: a library missing a method in its types
import 'some-charting-lib';

declare module 'some-charting-lib' {
  interface ChartInstance {
    exportToPng(options?: { scale?: number }): Promise<Blob>;
  }
}

This keeps the fix co-located with the project, version-controlled, and visible to the whole team — not buried in a comment next to an as any.

tRPC-Style End-to-End Type Safety

tRPC's central idea is powerful: define your API contract once in TypeScript, share it between server and client, and let the type system ensure they stay in sync. Even if you do not use tRPC itself, the pattern is applicable.

Here is the minimum viable version for a Next.js API route:

// lib/typed-api.ts

import { z, ZodSchema } from 'zod';

type ApiHandler<TInput, TOutput> = (input: TInput) => Promise<TOutput>;

export function createEndpoint<TInput, TOutput>(
  inputSchema: ZodSchema<TInput>,
  handler: ApiHandler<TInput, TOutput>,
) {
  return {
    schema: inputSchema,
    handler,
    // Type helper for client-side consumption
    call: null as unknown as ApiHandler<TInput, TOutput>,
  };
}

// api/endpoints/create-project.ts
const CreateProjectInput = z.object({
  name:     z.string().min(1).max(100),
  tenantId: z.string().transform(TenantId),
});

const CreateProjectOutput = z.object({
  id:        z.string().transform(UserId),
  createdAt: z.string().datetime(),
});

export const createProject = createEndpoint(
  CreateProjectInput,
  async (input) => {
    // input is fully typed: { name: string, tenantId: TenantId }
    const project = await db.projects.create(input);
    return CreateProjectOutput.parse(project);
  }
);

ts-pattern: Exhaustive Pattern Matching

TypeScript discriminated unions are excellent for domain modeling. But the standard approach — a switch statement — does not guarantee exhaustiveness in all situations. ts-pattern fills this gap:

import { match, P } from 'ts-pattern';

type PaymentState =
  | { status: 'idle' }
  | { status: 'pending'; attemptId: string }
  | { status: 'success'; transactionId: string; amount: number }
  | { status: 'failed'; error: string; retryable: boolean };

function renderPaymentUI(state: PaymentState) {
  return match(state)
    .with({ status: 'idle' },    () => <PayButton />)
    .with({ status: 'pending' }, ({ attemptId }) => <Spinner id={attemptId} />)
    .with({ status: 'success' }, ({ transactionId, amount }) => (
      <SuccessScreen id={transactionId} amount={amount} />
    ))
    .with({ status: 'failed', retryable: true },  ({ error }) => <RetryPrompt error={error} />)
    .with({ status: 'failed', retryable: false }, ({ error }) => <ErrorScreen error={error} />)
    .exhaustive(); // TypeScript error if a case is missing
}

The .exhaustive() call makes TypeScript error at compile time if a new state variant is added to PaymentState without updating the match. This is the pattern matching equivalent of branded types — it makes an entire class of runtime errors structurally impossible.

Anti-pattern Avoid using as type assertions to silence TypeScript. Every as unknown as SomeType is a promise to the compiler that you know better. Sometimes you do — but document why, and treat it as a code smell worth revisiting.

Keeping any Out at the Boundary

The most important rule: validate all external data with zod (or equivalent) at the system boundary. API responses, localStorage, URL params, and environment variables are all external data. Inside the boundary, the types are trustworthy. Outside it, nothing is.

// BAD — trusting an API response
const user = await fetch('/api/me').then(r => r.json()) as User;

// GOOD — validating at the boundary
const raw = await fetch('/api/me').then(r => r.json());
const user = UserSchema.parse(raw); // throws if invalid; user is typed as User

Key Takeaways

  • Brand every domain identifier — ID confusion bugs are common and fully preventable
  • Derive types from code with typeof and infer — never duplicate type definitions alongside data
  • Use module augmentation to fix third-party types properly — never reach for as any
  • Apply the tRPC pattern for type-safe API contracts, even without tRPC itself
  • Use ts-pattern with .exhaustive() for discriminated union handling — prevents unhandled states at compile time
  • Validate all external data with zod at the boundary — inside the boundary, types are a guarantee