All articles
TypeScript Architecture Frontend Backend

TypeScript Patterns for Large Codebases

Palakorn Voramongkol
March 18, 2025 10 min read

“TypeScript at scale requires different patterns than TypeScript for a side project. Here are the type patterns and architectural decisions that keep large codebases maintainable, based on managing 200K+ line TypeScript projects.”

TypeScript at Scale Is Different

When your TypeScript codebase is under 10K lines, almost any approach works. Types are loose, exports are casual, and refactoring is easy because you can hold the entire codebase in your head. At 50K+ lines with multiple contributors, the decisions you made early on start compounding — for better or worse.

I’ve maintained three TypeScript codebases above 200K lines of code, and the patterns that matter at scale are different from what tutorials teach.

Pattern 1: Branded Types for Domain Safety

Primitive obsession — using plain strings and numbers everywhere — is the single biggest source of bugs in large TypeScript codebases. When your function accepts a userId: string, nothing stops you from passing an orderId: string. TypeScript’s structural typing means both are just strings.

Branded types fix this by creating nominal type distinctions. You create a type that’s structurally a string but nominally distinct. A UserId can’t be passed where an OrderId is expected, even though both are strings at runtime. The runtime cost is zero — these are purely compile-time guardrails.

This pattern catches bugs that unit tests miss: passing the wrong ID to a function, mixing up currency amounts, or confusing timestamps with different semantics.

Pattern 2: Discriminated Unions for State Machines

Every non-trivial application has state machines: a request is loading, succeeded, or failed. A user is active, suspended, or deleted. An order is pending, paid, shipped, or cancelled.

Discriminated unions model these perfectly. Instead of a single type with optional fields (status: string, data?: T, error?: Error), create a union where each variant has exactly the fields that make sense for that state.

The compiler then enforces exhaustive handling — if you add a new state, every switch statement that handles the union will error until you handle the new case. This is incredibly valuable in a large codebase where state handling logic is spread across dozens of files.

Pattern 3: Strict Module Boundaries

In a large codebase, not every file should be importable from everywhere. Establish module boundaries using barrel exports (index.ts files) that explicitly declare a module’s public API.

The internal directory structure of a module is an implementation detail. External consumers import from the module’s index, not from internal files. This means you can refactor internals without breaking consumers, and your module’s public API is documented by its exports.

Enforce this with ESLint rules that prevent deep imports across module boundaries. Some teams use TypeScript path aliases to make module boundaries even more explicit.

Pattern 4: Generic Constraints for API Layers

Your API layer should be typed end-to-end, from the HTTP handler to the database query. Generic constraints make this possible without duplicating types.

Define your API contract as a type that maps routes to request/response types. Then write generic functions that accept a route key and automatically infer the correct request and response types. This gives you type safety from the API client through the handler to the database, with types defined in a single place.

When you change an API response type, every consumer that uses it incorrectly will fail at compile time. In a large codebase with dozens of API endpoints, this prevents an entire class of integration bugs.

Pattern 5: Utility Types for Transformation

TypeScript’s built-in utility types (Pick, Omit, Partial, Required) are powerful but insufficient for complex transformations. Build a library of domain-specific utility types.

Common patterns include: DeepPartial for nested optional fields in update operations, StrictOmit that errors if you try to omit a key that doesn’t exist, Prettify that flattens intersections for readable hover types, and NonEmptyArray for arrays that must have at least one element.

These utility types encode business rules at the type level. A function that requires a NonEmptyArray parameter can never receive an empty array, eliminating a class of runtime errors.

Pattern 6: Type-Safe Event Systems

Large applications often use event-driven patterns for cross-module communication. Without types, event systems become a maintenance nightmare — you’re passing untyped payloads through string-keyed channels.

Define a type map that associates event names with their payload types. Then write generic emit and listen functions that infer the correct payload type from the event name. Adding a new event means adding one entry to the type map, and all emitters and listeners get the correct types automatically.

Pattern 7: Const Assertions for Configuration

Large codebases have lots of configuration: feature flags, route definitions, permission matrices, error codes. Using const assertions (as const) turns these from loose types into literal types.

A configuration object defined with as const gives you exact string literal types for values, which enables exhaustive checking, autocomplete, and type narrowing that loose string types can never provide.

Pattern 8: Template Literal Types for String Patterns

TypeScript 4.1 introduced template literal types that let you define patterns for strings. This is invaluable for APIs that follow naming conventions.

You can define types for API routes, CSS class patterns, translation keys, or any other string that follows a predictable format. The compiler enforces the pattern at every usage site, catching typos and convention violations at compile time.

Compiler Configuration for Scale

Strict Mode Is Non-Negotiable

Enable strict: true from day one. Retrofitting strict mode onto a large codebase is painful and expensive. The individual flags it enables (strictNullChecks, noImplicitAny, strictFunctionTypes, etc.) each catch real bugs that would otherwise reach production.

Incremental Compilation

Enable incremental: true and tsBuildInfo output. On a 200K line codebase, this can reduce recompilation time from 30 seconds to 3 seconds. The compiler caches type information and only rechecks changed files and their dependents.

Project References

For monorepos, use TypeScript project references to break your codebase into independently compilable units. Each package gets its own tsconfig.json with a references array. The compiler builds packages in dependency order and caches results.

Testing Types

Don’t just test runtime behavior — test your types. Libraries like ts-expect-error comments, conditional types that resolve to never for invalid inputs, and dedicated type testing tools let you verify that your types catch errors as expected.

This is especially important for utility types and generic functions. A type that’s supposed to reject invalid input should have tests proving that invalid input causes a compile error.

The Long Game

TypeScript at scale is about leverage: every hour invested in good types saves hundreds of hours of debugging, code review, and runtime errors across the team. The patterns above aren’t about type gymnastics for their own sake — they encode business rules, prevent real bugs, and make refactoring safe in codebases too large for any one person to fully understand.

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

Written by Palakorn Voramongkol

Software Engineer Specialist with 20+ years of experience. Writing about architecture, performance, and building production systems.

More about me

Continue Reading