SyncTrix logoSyncTrix
All articles
Platform10 min read

TypeScript at scale: types that catch real bugs without fighting the compiler

Type-level cleverness impresses reviewers and slows everyone down. Here's where types genuinely prevent production bugs and where they are ceremony.

By Priya Iyer
TypeScript at scale: types that catch real bugs without fighting the compiler

TypeScript in a large codebase tends toward one of two failure modes. Either types are decorative - everything is loosely shaped and any appears wherever it is inconvenient - or types become a puzzle, with conditional generics nobody can modify without an afternoon of experimentation. The productive middle is narrower than it looks and worth being deliberate about.

01Make illegal states unrepresentable

The highest-value technique is also the simplest. A type with independent optional fields permits combinations that should never exist - loading and error simultaneously, data present while still loading - and every consumer must defensively handle contradictions that the type system invited.

A discriminated union collapses that space to exactly the valid states. The compiler then narrows correctly in each branch, and the impossible combinations are not handled defensively because they cannot be constructed. This one pattern eliminates more real bugs than any amount of generic machinery.

  • Model state as a union of valid shapes, not a bag of optional fields.
  • Give each variant a literal discriminant the compiler can narrow on.
  • Prefer several precise types over one type with many optional properties.
  • Use exhaustiveness checks so adding a variant produces a compile error.

02Types stop at the boundary

The most dangerous misconception is that a type annotation on parsed JSON means anything at runtime. It does not. An API response typed as User is a compile-time assertion about data that arrives at runtime from a system that may have changed since you wrote the type.

Every external boundary - HTTP responses, request bodies, environment variables, message payloads, database rows from untyped queries - needs runtime validation that produces the type as its output. A schema validation library gives you one definition serving both purposes, so the static type and the runtime check cannot drift apart.

03Branded types for values that get confused

A user ID and an organisation ID are both strings, so the compiler accepts either wherever the other is expected. In a function taking three string identifiers, the argument order is enforced by nothing but attention, and transposing two of them produces a bug that type checking was supposed to catch.

Branding attaches a phantom marker to a primitive so the types become incompatible despite the identical runtime representation. It costs a small amount of ceremony at the boundaries where values are created and eliminates an entire class of mix-ups everywhere else. Reserve it for identifiers and units that genuinely get confused - not every string.

04Where the effort pays and where it does not

Type-level programming has real uses and a strong pull toward overuse. The useful question is who benefits: types that constrain how code is called from many places earn their complexity, while types that make a single call site marginally more precise usually do not.

TechniquePayoffUse when
Discriminated unionsHighAlmost always - model state this way
Runtime schema validationHighEvery external boundary, no exceptions
Branded primitivesMedium-highIDs, units, values that get transposed
Generic utilitiesMediumShared library code with many callers
Conditional / mapped typesLow-mediumLibrary authorship, not application code
Template literal typesLowRarely worth it outside library internals
Type investment by payoff

05Strictness is only useful if it stays on

Enable strict mode and the additional checks for unchecked index access and exact optional properties. The index access flag in particular catches a genuine class of bug: array and record lookups can return undefined, and code that assumes otherwise fails on empty results.

Turning these on in an established codebase produces an intimidating error count. Migrate per directory rather than globally, and hold the line on new code while the old code catches up. The failure mode to avoid is a strict configuration undermined by suppression comments scattered through the codebase - that is worse than not enabling it, because it looks safe and is not.

Topics

typescript patternstype safetydiscriminated unionsbranded typestypescript at scaleruntime validation

Priya Iyer

Staff Engineer · SyncTrix

Writes about the engineering decisions behind production systems - architecture, delivery and the trade-offs that only show up at scale.

Building something like this?

SyncTrix engineers AI, SaaS, platform and cloud systems for enterprises and high-growth teams. Tell us what you're shipping and we'll scope it with you.