Back to Engineering Guides

TypeScript Advanced Patterns for Large-Scale Codebases

Technical Insight
Published November 18, 2025
TypeScript Advanced Patterns for Large-Scale Codebases

After TypeScript adoption crosses a certain threshold in a codebase, the basic generic patterns stop being sufficient. You start needing types that reason about other types — types that transform, filter, or infer from their inputs. This guide covers the advanced patterns we rely on at TechYantram for enterprise-scale codebases.

Discriminated Unions: The Foundation of State Machines

Discriminated unions are underused. They model states that are mutually exclusive and exhaustively typeable — which is exactly what most application states are:

type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error; retryCount: number };

function render<T>(state: RequestState<T>) {
  switch (state.status) {
    case 'idle':    return <EmptyState />;
    case 'loading': return <Spinner />;
    case 'success': return <DataView data={state.data} />;  // data is typed T here
    case 'error':   return <ErrorView error={state.error} retries={state.retryCount} />;
  }
  // TypeScript enforces exhaustiveness — no default needed
}

The discriminant field (status) lets TypeScript narrow each branch. No casting, no optional chaining to guard against undefined, no runtime surprises.

Mapped Types: Transform Interfaces Automatically

Mapped types iterate over the keys of an existing type to produce a new one. The built-in utility types (Partial, Required, Readonly) are all mapped types. Writing your own:

// Make every property nullable (not the same as Partial)
type Nullable<T> = { [K in keyof T]: T[K] | null };

// Create a "form state" version of any entity type
type FormState<T> = {
  [K in keyof T]: {
    value: T[K];
    error: string | null;
    touched: boolean;
  }
};

// Usage
type UserFormState = FormState<User>;
// Inferred as: { name: { value: string; error: string|null; touched: boolean }; ... }

Conditional Types and infer

Conditional types let you branch on type relationships. Combined with infer, you can extract parts of complex types:

// Extract the resolved type of a Promise
type Awaited<T> = T extends Promise<infer U> ? U : T;

// Extract the first argument type of any function
type FirstArg<T extends (...args: any[]) => any> =
  T extends (first: infer F, ...rest: any[]) => any ? F : never;

// Example: auto-type the parameter of an event handler
type ClickHandler = (event: MouseEvent) => void;
type HandlerEvent = FirstArg<ClickHandler>; // MouseEvent

A practical use: extracting return types from a service layer to avoid re-declaring them in the consuming component:

const userService = {
  getUser: async (id: string) => db.user.findUnique({ where: { id } }),
  listUsers: async () => db.user.findMany(),
};

type UserService = typeof userService;
type GetUserReturn = Awaited<ReturnType<UserService['getUser']>>;
// Inferred as: User | null  — from the Prisma schema, automatically

Template Literal Types

Template literal types compose string unions, useful for type-safe event names, CSS property names, or API endpoint keys:

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiVersion = 'v1' | 'v2';
type Resource = 'users' | 'orders' | 'products';

// Generates all valid endpoint strings
type Endpoint = `/${ApiVersion}/${Resource}`;
// '/v1/users' | '/v1/orders' | '/v1/products' | '/v2/users' | ...

// Typed event bus
type EntityEvent<E extends string, A extends string> = `${E}:${A}`;
type UserEvent = EntityEvent<'user', 'created' | 'updated' | 'deleted'>;
// 'user:created' | 'user:updated' | 'user:deleted'

function on(event: UserEvent, handler: () => void) { /* ... */ }
on('user:created', handler);  // ✓
on('user:banned', handler);   // ✗ Type error

Satisfies Operator: Infer Narrow, Validate Wide

The satisfies operator (TypeScript 4.9+) validates that a value matches a type while keeping the inferred type narrow:

type PaletteConfig = Record<string, string | [number, number, number]>;

// Without satisfies: type is widened to PaletteConfig, losing specifics
const palette: PaletteConfig = {
  primary: '#3b82f6',
  accent: [59, 130, 246],
};
palette.primary.toUpperCase(); // Error: string | [number,number,number] has no toUpperCase

// With satisfies: validated as PaletteConfig but type stays narrow
const palette2 = {
  primary: '#3b82f6',
  accent: [59, 130, 246],
} satisfies PaletteConfig;

palette2.primary.toUpperCase(); // ✓ TypeScript knows primary is a string
palette2.accent[0].toFixed(2); // ✓ TypeScript knows accent is a tuple

Branded Types: Prevent ID Mixups

In a codebase with many entity IDs, passing a userId where an orderId is expected is a runtime bug that TypeScript won't catch if both are string. Branded types fix this:

type UserId  = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };

const toUserId  = (id: string): UserId  => id as UserId;
const toOrderId = (id: string): OrderId => id as OrderId;

function getOrder(orderId: OrderId) { /* ... */ }

const uid = toUserId('user-123');
const oid = toOrderId('order-456');

getOrder(oid); // ✓
getOrder(uid); // ✗ Type error: UserId is not assignable to OrderId

Brands add zero runtime overhead — they're purely a compile-time fiction — but catch a whole class of logical errors that basic typing misses.

Putting It Together

These patterns compose. A production request handler might use discriminated unions for state, branded types for IDs, conditional types to infer DB return types, and satisfies for configuration objects — all in a way that reads cleanly and refactors safely. The investment in learning these patterns pays dividends as your codebase grows past the point where informal conventions can maintain coherence.

Distribute Knowledge

#TypeScript#JavaScript#Best Practices#Enterprise