Start a Project
All posts
DevelopmentApril 17, 202611 min read

TypeScript Best Practices for Large-Scale Applications

Engineering practices for TypeScript at scale: strict config, discriminated unions, branded types, satisfies, runtime validation with Zod, and monorepo setup.

TypeScript is the easy part. The hard part is keeping a TypeScript codebase honest after three years, twelve engineers, and a few hundred thousand lines of code. We have rescued enough projects from the "everything is any" phase to know what compounds and what just looks clever in a blog post. Here is the set of practices we apply on every long lived TypeScript codebase, with the reasoning behind each.

Turn the compiler all the way on

Most teams set strict: true and call it done. That covers the basics, but four newer flags are worth turning on explicitly. They catch real production bugs that strict does not.

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true
  }
}

noUncheckedIndexedAccess is the big one. Without it, arr[0] is typed as T, even though it might be undefined. With it, you are forced to handle the possibility, which prevents an entire category of runtime crashes when a list is empty or a key is missing.

exactOptionalPropertyTypes distinguishes between "the key is missing" and "the key is present with the value undefined". This matters for JSON APIs, form state, and anywhere you serialize objects.

noImplicitOverride forces you to mark overridden methods with the override keyword, so renaming a base class method does not silently orphan its subclasses.

Turn these on at the start of a new project. Turning them on later is a multi week cleanup we have done more than once.

Discriminated unions, not enums

State machines, API responses, and any "this is one of N shapes" data should be modeled as discriminated unions, not enums. Enums in TypeScript are an awkward runtime construct with surprising behavior. Unions are pure types, narrow correctly, and exhaust completely.

type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render(state: FetchState<User>) {
  switch (state.status) {
    case "idle":
      return null;
    case "loading":
      return <Spinner />;
    case "success":
      return <Profile user={state.data} />;
    case "error":
      return <ErrorBanner message={state.error.message} />;
  }
}

If you add a fifth state and forget a case, the compiler tells you. With enums and a generic data?: T field, you would not get that protection, and the unsafe access to state.data in the loading case would slip through.

as const and satisfies for narrowing without losing inference

The satisfies operator was the biggest practical TypeScript change of the last few years. It lets you constrain a value to a type without widening the inferred type. The difference matters when you want autocomplete on specific keys but also want the compiler to enforce a shape.

type Route = { path: string; auth: boolean };

const routes = {
  home:    { path: "/",        auth: false },
  account: { path: "/account", auth: true  },
  admin:   { path: "/admin",   auth: true  },
} satisfies Record<string, Route>;

// routes.account is { path: string; auth: boolean }
// but routes.unknown is a type error, not undefined
type RouteName = keyof typeof routes; // "home" | "account" | "admin"

Combine as const with satisfies when you want literal types preserved:

const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE"] as const satisfies readonly string[];
type HttpMethod = (typeof HTTP_METHODS)[number]; // "GET" | "POST" | "PUT" | "DELETE"

This pattern replaces most legitimate uses of enums.

Branded types for IDs and units

A string is a string. A UserId is also a string to the runtime, but conflating them is the source of subtle, expensive bugs. Branded types use a phantom property to make IDs nominally distinct.

declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type ProductId = Brand<string, "ProductId">;
type Cents = Brand<number, "Cents">;

function asUserId(s: string): UserId {
  return s as UserId;
}

function getProduct(id: ProductId) { /* ... */ }

const u = asUserId("u_abc123");
// getProduct(u); // Type error. Caught at compile time.

We brand IDs, money amounts, durations, and anything else where the unit matters. The runtime cost is zero. The bug prevention is real, especially in checkout flows and analytics code.

Project references and path aliases for monorepos

Once your repo has more than three or four packages, you want TypeScript project references. They give you per package incremental builds, enforced boundaries between packages, and faster editor performance.

// packages/web/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "composite": true,
    "paths": {
      "@acme/ui": ["../ui/src/index.ts"],
      "@acme/data": ["../data/src/index.ts"]
    }
  },
  "references": [
    { "path": "../ui" },
    { "path": "../data" }
  ]
}

Path aliases keep imports clean (import { Button } from "@acme/ui" instead of ../../../ui/src). Project references make the compiler aware of build order and let tsc --build skip work that has not changed. Editor jump-to-definition keeps working across packages.

Stop reaching for any

any is a compiler off switch. Every any is a hole in your type system that propagates outward. When you are stuck, reach for these in order:

  1. unknown plus a narrowing check. This is what any should have been.
  2. A discriminated union, when the value has a known set of shapes.
  3. A generic, when the type depends on the caller.
  4. A // @ts-expect-error with a comment explaining why, so the error reappears if the situation changes.

If you genuinely need to escape the type system at a boundary, use unknown and validate. Speaking of which.

Validate at the boundary with Zod

The TypeScript compiler trusts the network. The network lies. Any data crossing into your app from outside, API responses, form submissions, environment variables, message queues, has to be validated at runtime. Zod is our default.

import { z } from "zod";

const UserSchema = z.object({
  id: z.string().brand<"UserId">(),
  email: z.string().email(),
  role: z.enum(["admin", "member", "viewer"]),
  createdAt: z.coerce.date(),
});

type User = z.infer<typeof UserSchema>;

export async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const json = await res.json();
  return UserSchema.parse(json); // throws if shape is wrong
}

You get a single source of truth for the shape, a runtime guard, and an inferred TypeScript type. We use the same pattern for env vars (parse process.env once at startup and export a typed env object) and for every API route handler input.

The opinionated practices

Config flags only get you to "the compiler is helping." These are the practices that compound over years:

  • Make illegal states unrepresentable. If two booleans can never both be true, model them as a union with one tag, not two booleans.
  • Push types to the edges. Validate input at the boundary, then let inference flow inward. Avoid annotating every function parameter when the call site already knows.
  • Generics are a tool, not a goal. A function with five type parameters is a code smell. Try writing it concrete first and extract a generic only when you need it twice.
  • Resist clever types in shared code. A conditional type that takes ten minutes to read is a tax on every engineer who touches the file. Save the gymnastics for libraries.
  • Treat the build as a first class artifact. A tsc --noEmit step in CI is non negotiable. A green test suite with a red type check is not green.

We have led TypeScript migrations on codebases that had drifted into chaos and built new ones from a clean tsconfig. The teams that stay healthy are the ones that treat TypeScript as a design tool, not a syntax tax. If you want a second pair of eyes on your codebase or help setting up these patterns in a new project, get in touch. We do this work.

Tags

TypeScript best practicesTypeScript strict modediscriminated unions TypeScriptbranded types TypeScriptZod runtime validationTypeScript monoreposatisfies operatorTypeScript at scale

Ready to Build Something Great?

Let's discuss your project and explore how we can help you achieve your digital goals. From concept to launch, we're here to make it happen.

One business-day reply

Initial scoping call within the week.

No-cost project audit

We start by understanding what you actually need.

Transparent pricing

Fixed scope, retainer, or team-extension. Your call.