Apr 8, 2026 5 min read

TypeScript at Scale: 10 Practices That Survive 100k Lines

Every TypeScript tips list tells you to enable strict mode. Fine – but the interesting problems start later, when the codebase passes 100k lines, ten people commit daily, and the type system is either your architecture's immune system or a decorative layer everyone as-casts through. These are the ten practices I've seen actually hold up in codebases that size – and the ones I enforce when I lead teams.

1. Strict is the floor, not the goal

"strict": true plus the flags it doesn't include: noUncheckedIndexedAccess (array access returns T | undefined – annoying for a week, then it prevents real crashes) and exactOptionalPropertyTypes. Adopting these late is painful; adopt them on day one or during a quiet sprint with // @ts-expect-error as a paper trail, never as a permanent lifestyle.

2. Model states, not fields

The single highest-leverage habit. A bag of optional fields is a lie about what combinations exist:

// Four booleans, sixteen "states", three of them real
type Bad = { loading?: boolean; error?: string; data?: Report };

// The truth
type ReportState =
  | { status: 'loading' }
  | { status: 'error'; error: string }
  | { status: 'ready'; data: Report };

Discriminated unions turn "did someone check error before reading data?" from a code-review question into a compiler error. UI state, API responses, form flows, feature flags – model them all this way.

3. Make illegal states unrepresentable at the boundaries

Inside your app, trust the types. At the edges – HTTP responses, localStorage, URL params, webhook payloads – trust nothing. Type everything arriving from outside as unknown and parse it with a schema validator (zod or similar) that derives the TypeScript type:

const Invoice = z.object({ id: z.string(), total: z.number() });
type Invoice = z.infer<typeof Invoice>;   // one source of truth

const invoice = Invoice.parse(await res.json()); // runtime + compile time agree

Most "TypeScript didn't save us" production incidents I've debugged were an unvalidated boundary wearing a confident type.

4. Exhaustiveness is a feature you write once

Every switch over a union gets a never check:

default: {
  const _exhaustive: never = state;   // adding a variant breaks the build here
  throw new Error(`Unhandled: ${_exhaustive}`);
}

When someone adds status: 'archived' next quarter, the compiler hands them a to-do list of every place that must care. That's the type system doing project management.

5. satisfies for configs, as const for literals

satisfies validates a value against a type without widening it – made for config objects, route tables, and design tokens:

const routes = {
  home: '/',
  invoice: (id: string) => `/invoices/${id}`,
} satisfies Record<string, string | ((...a: never[]) => string)>;

routes.invoice('42');  // still knows it's a function – plain annotation would forget

6. Brand the IDs that must not mix

When userId and invoiceId are both string, the compiler happily lets you bill the wrong entity. A branded type costs one line and makes the confusion unrepresentable:

type UserId = string & { readonly __brand: 'UserId' };

Reserve it for the identifiers where a mix-up is expensive – money, auth, tenancy. Branding everything is ceremony; branding nothing is roulette.

7. Generics are a budget – spend them where they earn

A generic earns its place when it connects an input type to an output type (function pluck<T, K extends keyof T>(...)). It's noise when it's a fancy way to say any, or when three type parameters with defaults guard a function called two ways. In review, my question is always: "what bug does this type parameter prevent?" No answer, no generic. The same goes for clever conditional-type gymnastics: every minute a teammate spends decoding a type is a minute the type cost, not saved.

8. Types flow up, not sideways

Large-codebase type rot usually starts with imports reaching across feature boundaries for a convenient interface, until everything depends on everything. Give shared domain types an explicit home (@app/domain or equivalent), keep feature-internal types internal, and let the dependency direction match your architecture. If you can't tell where a type should live, that's an architecture finding, not a TypeScript one – the kind of thing an audit surfaces early.

9. Kill the escape hatches in CI, not in review

Humans shouldn't argue about any in pull requests; linters should reject it. @typescript-eslint/no-explicit-any, no-unsafe-*, and a ban on bare as casts (allow as const and as unknown as T behind a comment explaining itself) move the debate from opinion to policy. Budget the existing violations, ratchet the number down monthly, and the codebase heals without a heroic cleanup sprint.

10. Measure the compiler like you measure the bundle

Past a certain size, tsc time is developer experience. Incremental builds, project references for real module boundaries, and an occasional tsc --extendedDiagnostics run catch the type that accidentally instantiates half the codebase. A type system nobody waits on is a type system nobody bypasses.


The common thread: TypeScript pays off when it encodes decisions – what states exist, what crosses a boundary, what must never mix – not when it decorates code that was already written. That's also the order I introduce these practices into teams: states first, boundaries second, lint policy third; the rest follows.

If your TypeScript codebase has drifted into any-and-hope territory and features are slowing down, that's a fixable, well-understood problem – architecture audits and hands-on rescue are what I do, and sinclar96@gmail.com is where it starts.

← All posts

Building something in this stack?

Get in touch