TypeScript at Scale: Patterns for Large Codebases
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.
{
"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.
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.
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 errorUse 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.
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.