TypeScriptDXArchitecture

TypeScript at Scale: Patterns for Large Codebases

16 min read
TypeScript's type system is powerful enough to encode complex business logic, but with great power comes the risk of over-engineering types or, conversely, undermining them with escape hatches. This guide covers proven patterns for keeping TypeScript productive and maintainable as your codebase scales to hundreds of files and multiple teams.

Start Strict, Stay Strict

Enable strict mode from day one. Retro-fitting strictness onto a large codebase is painful and error-prone. The strict flag enables noImplicitAny, strictNullChecks, strictFunctionTypes, and several other checks that catch real bugs. If you inherit a non-strict codebase, migrate incrementally using the // @ts-strict-check comment per file or use a tool like ts-strictify to track progress.

tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  }
}

Discriminated Unions for State Modeling

Discriminated unions are one of TypeScript's most powerful patterns for modeling state machines and API responses. Instead of using optional fields with undefined checks scattered throughout your code, define explicit variants with a common discriminant property. This forces exhaustive handling at every consumption point and makes impossible states unrepresentable.

types/api.ts
type ApiResponse<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error }

function renderResult(response: ApiResponse<User[]>) {
  switch (response.status) {
    case "idle":
      return null
    case "loading":
      return <Spinner />
    case "success":
      return <UserList users={response.data} />
    case "error":
      return <ErrorMessage error={response.error} />
  }
}

Branded Types for Domain Safety

Primitive types like string and number carry no semantic meaning. A userId and an orderId are both strings, but passing one where the other is expected is a bug. Branded types (also called nominal types or opaque types) add compile-time distinction to primitive types without any runtime overhead.

types/branded.ts
type Brand<T, B extends string> = T & { readonly __brand: B }

type UserId = Brand<string, "UserId">
type OrderId = Brand<string, "OrderId">

function getUser(id: UserId) { /* ... */ }
function getOrder(id: OrderId) { /* ... */ }

const userId = "usr_123" as UserId
const orderId = "ord_456" as OrderId

getUser(userId)   // OK
getUser(orderId)  // Type error
tip

Use branded types for IDs, currency amounts, validated strings (like email addresses), and any primitive that represents a distinct domain concept.

Module Boundaries and Barrel Exports

As codebases grow, managing imports becomes critical. Define clear module boundaries using barrel exports (index.ts files) that expose the public API of each module. Internal implementation details should not be importable from outside the module. Configure ESLint import rules or TypeScript path mappings to enforce these boundaries. Be cautious with barrel exports in library code — they can prevent tree-shaking if not configured correctly.

  • Use index.ts files to define the public API of each module
  • Keep internal helpers and types unexported
  • Enforce import boundaries with eslint-plugin-import or path aliases
  • Avoid circular dependencies by structuring modules in a dependency graph

Leverage Inference, Annotate Boundaries

TypeScript's type inference is excellent. Resist the urge to annotate every variable — it creates noise and maintenance burden. Instead, annotate at module boundaries: function parameters, return types of exported functions, and public API interfaces. Let inference handle local variables, intermediate computations, and private implementation details. This balance gives you both safety at the boundaries and ergonomics in implementation.

note

Always annotate return types of exported functions. This prevents accidental API changes and improves compile-time performance in large projects by giving TypeScript explicit boundaries for type checking.