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.
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) // OKIn 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 givesatisfies 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 exportedNX'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 | undefinedIf 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.