Home/Blog

TypeScript Best Practices for Large Codebases

TypeScript practices that hold up at scale: strict tsconfig flags, runtime validation at boundaries, discriminated unions, lint rules and fast type checks.

TypeScript Best Practices for Large Codebases

TypeScript scales when four things are true. The compiler is strict (strict plus noUncheckedIndexedAccess), so the types describe reality instead of hopes. Every value that crosses a trust boundary (requests, environment variables, third-party APIs, the database) is validated at runtime with a schema the types are derived from, because types are erased and prove nothing about incoming data. State is modeled with discriminated unions and exhaustive checks, so impossible states cannot be written. And type checking stays fast enough that nobody is tempted to skip it, through package boundaries, explicit return types on exports, and lint rules that catch what the compiler does not.

Key takeaways

  • strict is the floor. Add noUncheckedIndexedAccess and verbatimModuleSyntax, and choose module settings that match how the code actually runs.
  • Types are only as honest as your boundaries. Validate with a schema at every entry point and infer the type from the schema.
  • Use unknown instead of any, satisfies instead of as, and string literal unions instead of enums.
  • Model state with discriminated unions and make every switch exhaustive, enforced by the compiler and a lint rule.
  • Treat type-check time as a production metric for the codebase. Package boundaries and explicit export types keep it flat as the code grows.

A tsconfig baseline that scales

Put shared options in one base config that every package extends:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2023", "DOM", "DOM.Iterable"],
    "module": "preserve",
    "moduleResolution": "bundler",
    "moduleDetection": "force",
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noPropertyAccessFromIndexSignature": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "noEmit": true
  }
}

What the less obvious flags buy you:

FlagWhat it catches or changesCost
strictImplicit any, unchecked null and undefined, unsafe function types, unknown in catchNone worth arguing about
noUncheckedIndexedAccessitems[i] and record[key] become possibly undefined, matching runtimeSome extra checks; finds real bugs
noPropertyAccessFromIndexSignatureForces obj["key"] for index-signature properties, so dynamic lookups are visibleMinor
verbatimModuleSyntaxType-only imports must say import type, and are erased predictablySmall migration
noImplicitOverrideSubclass methods must say override, so a renamed base method is caughtSmall
exactOptionalPropertyTypesDistinguishes "missing" from "explicitly undefined"Noisy with some libraries; good for new code
skipLibCheckSkips checking .d.ts files in dependenciesFaster; hides conflicts between dependency typings

"module": "preserve" (TypeScript 5.4 and later) suits code that a bundler (Vite, Next.js, esbuild) processes. For code Node runs directly, use "module": "nodenext". If you run TypeScript files in Node through type stripping, add "erasableSyntaxOnly": true (TypeScript 5.8 and later), which rejects syntax that needs code generation, such as enums and parameter properties.

Turning strict on in an existing codebase

Do not try to fix every error in one pull request. Keep a tsconfig.strict.json that extends the main config with the strict flags, count its errors in CI, and fail the build if the count rises:

count=$(npx tsc --noEmit -p tsconfig.strict.json | grep -c "error TS" || true)
baseline=$(cat .strict-baseline)
if [ "$count" -gt "$baseline" ]; then
  echo "Strict errors rose from $baseline to $count. Fix new code before merging."
  exit 1
fi

Every pull request that fixes errors lowers the number in .strict-baseline. New code is strict from day one, old code converges, and when the count reaches zero the strict config becomes the main one. In a monorepo, flip packages one at a time instead.

Validate at the boundaries, infer the types

The most dangerous line in a TypeScript codebase is a cast on external data:

const user = (await res.json()) as User; // a promise, not a check

res.json() returns any. The cast tells the compiler to stop asking. If the API changes shape, the error appears three files later as undefined is not a function. Validate instead, and derive the type from the schema so they cannot drift:

import { z } from "zod";
 
export const CreateInvoice = z.object({
  customerId: z.string().min(1),
  currency: z.enum(["USD", "EUR", "INR"]),
  dueDate: z.coerce.date(),
  lines: z
    .array(
      z.object({
        description: z.string().min(1).max(200),
        quantity: z.number().int().positive(),
        unitCents: z.number().int().nonnegative(),
      }),
    )
    .min(1),
});
 
export type CreateInvoice = z.infer<typeof CreateInvoice>;
 
export async function POST(req: Request) {
  const parsed = CreateInvoice.safeParse(await req.json());
  if (!parsed.success) {
    return Response.json({ error: "invalid_body", issues: parsed.error.issues }, { status: 422 });
  }
  const invoice = await createInvoice(parsed.data); // typed and validated; dueDate is a real Date
  return Response.json(invoice, { status: 201 });
}

The boundaries to cover:

  • Incoming requests: bodies, query strings, route params, webhook payloads.
  • Environment variables: parse process.env once at startup and fail fast, so a missing secret crashes the deploy instead of the first request that needs it.
  • Third-party API responses: validate the fields you use; tolerate the ones you do not.
  • Storage: localStorage, files, queue messages, and JSON columns in the database.
const Env = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]),
  DATABASE_URL: z.string().min(1),
  PORT: z.coerce.number().int().default(3000),
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
});
 
export const env = Env.parse(process.env);

Zod is the common choice; Valibot and ArkType make different size and speed trade-offs, and all three implement the Standard Schema interface, so libraries that accept one usually accept the others. For types that come from a contract (OpenAPI, GraphQL, the database schema), generate them instead of writing them by hand. Our REST vs GraphQL guide covers keeping those contracts as the source of truth.

unknown, satisfies, and the casts you should not write

  • unknown instead of any. unknown forces a check before use; any turns the checker off for everything it touches, and it spreads through inference.
  • satisfies instead of annotations on config objects. It checks the value against a type without widening it, so you keep the precise keys and literals:
type Route = { path: `/${string}`; auth: "public" | "user" | "admin" };
 
export const routes = {
  home: { path: "/", auth: "public" },
  billing: { path: "/billing", auth: "admin" },
} satisfies Record<string, Route>;
 
routes.billing.auth; // type is "admin", and an invalid path or auth value is a compile error
  • Type guards instead of as. If you must narrow, write a function that checks: function isApiError(e: unknown): e is ApiError. A cast documents an assumption; a guard tests it.
  • @ts-expect-error with a reason, never @ts-ignore. @ts-expect-error fails when the error it suppresses goes away, so stale suppressions get cleaned up. Require a description with the lint rule below.

Model state with discriminated unions

A set of optional fields (isLoading, data?, error?) allows states that make no sense, such as loading with an error and data at once. A discriminated union makes them unrepresentable:

type RemoteData<T, E = Error> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: E };
 
export function assertNever(value: never): never {
  throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
 
function renderInvoices(state: RemoteData<Invoice[]>) {
  switch (state.status) {
    case "idle":
      return null;
    case "loading":
      return <Spinner />;
    case "success":
      return <InvoiceTable invoices={state.data} />;
    case "error":
      return <ErrorPanel error={state.error} />;
    default:
      return assertNever(state);
  }
}

Inside each case, TypeScript narrows state, so state.data exists only on success. The default branch is the important part: add a "stale" status next quarter, and every switch that forgot it fails to compile. The same pattern fits payment states, order lifecycles, form steps, and domain events.

Branded types for values that look alike

userId and orderId are both strings; passing one where the other belongs is always a bug, and the compiler cannot see it. Brands make it visible:

declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
 
export type UserId = Brand<string, "UserId">;
export type OrderId = Brand<string, "OrderId">;
export type Cents = Brand<number, "Cents">;
 
export function toCents(value: number): Cents {
  if (!Number.isInteger(value)) throw new RangeError(`Not a whole number of cents: ${value}`);
  return value as Cents;
}
 
declare function getOrder(id: OrderId): Promise<Order>;
declare const userId: UserId;
 
getOrder(userId); // compile error: UserId is not assignable to OrderId

The only place a brand is applied is a constructor that validates, which makes the brand mean "this has been checked". Zod's .brand() does the same at a schema boundary. Use brands for IDs, money, and validated strings; do not brand everything, or the ceremony outweighs the benefit.

Errors: return the expected, throw the unexpected

TypeScript cannot track what a function throws, so callers forget to handle failures that are part of normal business. Split failures by kind:

FailureExampleMechanism
Expected outcomeCard declined, not found, validation failedReturn a discriminated union the caller must handle
Broken invariant or bugImpossible state, missing configThrow, crash loudly, fix the code
Infrastructure failureDatabase down, timeoutThrow, catch at the request boundary, log with the cause
type ChargeResult =
  | { ok: true; chargeId: string }
  | { ok: false; reason: "card_declined" | "insufficient_funds" | "expired_card" };
 
async function charge(customerId: string, amount: Cents): Promise<ChargeResult> {
  try {
    const res = await payments.charge({ customerId, amountCents: amount });
    return { ok: true, chargeId: res.id };
  } catch (err) {
    const reason = declineReason(err);
    if (reason) return { ok: false, reason };
    throw new Error(`Charge failed for customer ${customerId}`, { cause: err });
  }
}

The cause option keeps the original error and stack attached when you wrap one, so logs show both. Libraries such as neverthrow add map and andThen helpers to result types; a plain union is often enough.

Lint what the compiler does not check

typescript-eslint's type-aware rules catch whole classes of production bugs that compile cleanly:

// eslint.config.js
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
 
export default defineConfig(
  tseslint.configs.strictTypeChecked,
  {
    languageOptions: {
      parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
    },
    rules: {
      "@typescript-eslint/no-floating-promises": "error",
      "@typescript-eslint/no-misused-promises": "error",
      "@typescript-eslint/switch-exhaustiveness-check": "error",
      "@typescript-eslint/consistent-type-imports": "error",
      "@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": "allow-with-description" }],
    },
  },
);

no-floating-promises alone justifies the setup: an un-awaited promise that rejects becomes an unhandled rejection, which can crash a Node process or silently drop a write. no-misused-promises catches async functions passed where a void callback is expected, such as an async onClick whose errors vanish.

Keep type checking fast

A type check that takes minutes gets skipped locally, and then CI is the first place errors appear. Treat check time as something you measure.

  • Split into packages with project references, or give each package its own cached type-check task. Unchanged packages are not rechecked.
{
  "files": [],
  "references": [
    { "path": "./packages/domain" },
    { "path": "./packages/api-client" },
    { "path": "./apps/web" }
  ]
}

Each referenced package sets "composite": true, and tsc -b builds them in dependency order, reusing results for packages that did not change.

  • Annotate return types on exported functions. Consumers then read a declared type instead of re-inferring through your implementation, and isolatedDeclarations (TypeScript 5.5 and later) can enforce it so declaration files can be generated in parallel.
  • Find the expensive types. Measure before guessing:
npx tsc --noEmit --extendedDiagnostics
npx tsc --noEmit --generateTrace ./ts-trace
npx @typescript/analyze-trace ./ts-trace

The trace usually points at a handful of deep conditional types, enormous unions, or a generic helper inferring through a large object. Simplify those; the rest of the codebase is rarely the problem. TypeScript 7, Microsoft's native port of the compiler released in July 2026, attacks checker speed directly and is often about 10 times faster than TypeScript 6. It does not yet expose a stable programmatic API, so tools that embed the compiler can still require TypeScript 6; typescript-eslint, for example, supports TypeScript versions below 6.1. The structural fixes above help regardless of which compiler you run.

Organize types by feature

Co-locate types with the code that uses them and avoid one giant types.ts. Shared types live in a shared package only when two features genuinely depend on them. Use interface for object shapes others extend and type for unions and compositions, and pick one convention for plain object shapes. Use import type for type-only imports (verbatimModuleSyntax enforces it), which keeps runtime import graphs, and accidental circular dependencies, smaller.

What goes wrong in production

The compiler passes; production still breaks. The usual reasons:

  • Casts on external data. as User on an API response. The API changes, the type does not, and the bug appears far from its cause. Validate at the boundary.
  • Dates that are strings. A Date serialized to JSON comes back as a string, but the type still says Date. Parse dates in the schema (z.coerce.date()), not with a cast.
  • Floating promises. A forgotten await on a write, so errors vanish and ordering breaks. Lint for it.
  • Non-null assertions everywhere. After enabling noUncheckedIndexedAccess, someone adds ! to every index. That is a cast with extra steps. Handle the missing case or restructure the data.
  • Enums across boundaries. Numeric enums accept any number, and reordering members changes their values in stored data. Prefer string literal unions or as const objects.
  • Types that disagree with the runtime. @types/node for a newer Node than you deploy, or a library's types out of step with its code. Pin @types/node to the runtime major you run.
  • Suppression debt. Hundreds of @ts-expect-error comments that nobody revisits. Track the count like the strict baseline above, and require a reason on each.

Types reduce one class of bug; tests catch the rest. Our testing strategies guide covers how the two layers divide the work.

Frequently Asked Questions

What tsconfig settings should a large TypeScript project use?

Start with strict set to true, then add noUncheckedIndexedAccess, noImplicitOverride, noFallthroughCasesInSwitch and verbatimModuleSyntax. Pick module settings that match how the code runs: preserve with bundler resolution for bundled apps, nodenext for code Node runs directly. Share one base config across packages and override only what differs.

Should I use type or interface in TypeScript?

Both work for object shapes, and consistency matters more than the choice. A common rule is interface for object shapes that other code extends, and type for unions, intersections, mapped types and aliases. Only type can express unions, so codebases that model state with discriminated unions end up using type heavily.

Is it ever acceptable to use any in TypeScript?

Rarely, and never silently. Use unknown for values whose type you do not know yet, and narrow them with validation or type guards. Where any is genuinely needed, such as a narrow escape hatch around a badly typed library, isolate it in one small function with a comment, and let a lint rule flag every other use.

Do I still need runtime validation if I use TypeScript?

Yes. TypeScript types are erased at compile time, so they say nothing about data that arrives at runtime from HTTP requests, databases, environment variables, files or third-party APIs. Validate at those boundaries with a schema library such as Zod and derive the TypeScript type from the schema, so the check and the type cannot drift apart.

Should I use enums in TypeScript?

Usually not. A union of string literals, or a const object with as const, gives the same safety with plain JavaScript output and better interop. Enums generate runtime code, numeric enums accept any number, and enums are not erasable syntax, so they do not work when Node runs TypeScript by stripping types.

How do I speed up TypeScript type checking in a large codebase?

Split the code into packages with project references or per-package type-check tasks so unchanged code is not rechecked, add explicit return types to exported functions, and enable skipLibCheck. Then measure with the extendedDiagnostics and generateTrace compiler options to find the specific types that are expensive. Deep conditional types and very large unions are the usual culprits.

Need help with a large TypeScript codebase?

We maintain the TypeScript we write, which is why we care more about honest boundaries and fast checks than clever types. If you are tightening a codebase that has drifted into any, migrating to strict mode without stopping feature work, or hardening an AI-generated prototype for production, talk to the senior engineer who would do the work.