Back to Blog
5 min read

TypeScript Best Practices for Large Codebases — What I Wish I Knew Earlier

Patterns, compiler flags, and architecture decisions that make TypeScript actually work at scale — from strict mode to branded types to module boundaries.

TypeScript Best Practices for Large Codebases — What I Wish I Knew Earlier

Working across NX monorepos with multiple apps and shared libraries in TypeScript has forced me to learn the hard way what works at scale. These are the practices I now apply from day one.

Enable Strict Mode — All of It

This is non-negotiable:

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

noUncheckedIndexedAccess alone will catch a class of runtime bugs before they exist. array[0] now returns T | undefined, forcing you to handle the empty case.

exactOptionalPropertyTypes is the subtle one: with it, { foo?: string } means the property either doesn't exist or is a string — never { foo: undefined }. This matches what the runtime actually does.

Branded Types for IDs

The most underused TypeScript feature:

// Without branding — these are interchangeable at the type level
type UserId = string
type OrderId = string
 
function getOrder(orderId: OrderId) { ... }
 
const userId: UserId = "abc123"
getOrder(userId) // No error! But wrong at runtime.
// With branding — compile-time protection
type Brand<T, Brand extends string> = T & { __brand: Brand }
 
type UserId = Brand<string, "UserId">
type OrderId = Brand<string, "OrderId">
 
function toUserId(id: string): UserId { return id as UserId }
function toOrderId(id: string): OrderId { return id as OrderId }
 
const userId = toUserId("abc123")
const orderId = toOrderId("xyz789")
 
getOrder(userId) // Compile error: UserId is not assignable to OrderId
getOrder(orderId) // OK

In a system with many entity types, this prevents entire classes of bugs.

Discriminated Unions for State

Stop using status: string and separate optional fields. Use discriminated unions:

// Bad — all fields exist on all states, most are undefined
type Order = {
  status: "pending" | "processing" | "completed" | "failed"
  processedAt?: Date
  completedAt?: Date
  failureReason?: string
}
 
// Good — each state has exactly the fields it needs
type Order =
  | { status: "pending"; createdAt: Date }
  | { status: "processing"; processedAt: Date }
  | { status: "completed"; completedAt: Date; invoiceId: string }
  | { status: "failed"; failedAt: Date; reason: string }
 
function handleOrder(order: Order) {
  switch (order.status) {
    case "completed":
      console.log(order.invoiceId) // TypeScript knows invoiceId exists here
      break
    case "failed":
      console.log(order.reason) // TypeScript knows reason exists here
      break
  }
}

Exhaustiveness checking — add a never default case and TypeScript will error if you forget a branch.

satisfies Operator

Introduced in TS 4.9, underused everywhere:

const config = {
  endpoints: {
    users: "/api/users",
    orders: "/api/orders",
  },
  timeout: 5000,
} satisfies AppConfig
 
// config.endpoints.users is still typed as string literal "/api/users"
// not the wider string type that a type assertion would give

satisfies validates the shape without widening the type. Use it for config objects, color maps, route definitions.

Zod at System Boundaries

TypeScript types are erased at runtime. At system boundaries — API responses, form inputs, environment variables — validate with Zod:

import { z } from "zod"
 
const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  role: z.enum(["admin", "user", "moderator"]),
  createdAt: z.coerce.date(),
})
 
type User = z.infer<typeof UserSchema>
 
// At the API boundary
const parseUser = (raw: unknown): User => {
  return UserSchema.parse(raw) // throws if invalid
}

This pattern — Zod at the boundary, TypeScript in the interior — means you can trust your types everywhere except the entry points, which you validate explicitly.

Module Boundaries in NX Monorepos

In an NX monorepo, each library should have an explicit public API (index.ts) that exports only what's meant to be shared. Everything else is private:

// libs/orders/src/index.ts — PUBLIC API
export { OrderService } from "./services/order.service"
export type { Order, CreateOrderDto } from "./types"
// Internal utils, repositories, etc. are NOT exported

NX's module boundary lint rules enforce this:

// .eslintrc.json
{
  "rules": {
    "@nx/enforce-module-boundaries": ["error", {
      "depConstraints": [
        {
          "sourceTag": "scope:frontend",
          "onlyDependOnLibsWithTags": ["scope:frontend", "scope:shared"]
        }
      ]
    }]
  }
}

This prevents the "everything depends on everything" graph that kills maintainability in large monorepos.

Type-Safe Environment Variables

Stop using process.env.SOMETHING everywhere with implicit string | undefined:

// env.ts — validated at startup
import { z } from "zod"
 
const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  PORT: z.coerce.number().default(3000),
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
})
 
export const env = EnvSchema.parse(process.env)
// env.DATABASE_URL is string, not string | undefined

If a required env variable is missing, the app crashes at startup with a clear error — not deep in a request handler with a cryptic undefined bug.

These patterns compound. Applied together from the start of a project, they make large TypeScript codebases genuinely maintainable.