TypeScript Strict Mode in Production: The Patterns That Win Across a 50K-LOC Codebase
Strict Mode Is the Floor
TypeScript strict mode — the strict: true compiler flag, which enables noImplicitAny, strictNullChecks, strictFunctionTypes, and friends — is non-negotiable for any production codebase. If you are working in a TypeScript project where strict is off, fix that before doing anything else. The arguments against strict mode are arguments for cutting corners, and they age badly the moment the codebase grows past one or two contributors.
That said, strict mode is the floor, not the ceiling. The patterns that actually win in large codebases come after you have strict on. They are the conventions that turn TypeScript from a layer of safety into a layer of design — where the type system actively guides developers toward correct code rather than just catching mistakes after the fact.
Branded Types for Identifier Safety
One of the most common bugs in any application is passing the wrong ID to the wrong function. A function expects a UserId. The caller passes an OrganizationId. Both are strings, so TypeScript happily accepts the call. The bug shows up at runtime as a database miss or, worse, a silent data leak.
Branded types fix this. The pattern: define UserId as type UserId = string & { __brand: 'UserId' }. The intersection with the brand object is purely a type-level marker — at runtime, UserId is just a string. But the type system now refuses to assign one branded type to another. Every function that accepts a UserId can only be called with a value explicitly typed as UserId. The brand has to be applied at the boundary where the ID enters your system (usually a database read or an API parse), and it propagates from there. This single pattern eliminates an entire category of production bugs in any codebase that handles multiple kinds of identifiers.
Exhaustive Switch Checks with Never
When you have a discriminated union — a type like type Event = { kind: 'create' } | { kind: 'update' } | { kind: 'delete' } — and you switch on the discriminator, you want the type system to enforce that every case is handled. The pattern: the default case calls a function that takes never as its argument. const _exhaustive: never = event. If you add a new variant to the union and forget to handle it in the switch, TypeScript will reject the assignment to never and tell you exactly which case is missing.
This is the closest TypeScript gets to a true sum type with compile-time exhaustiveness checking. Use it everywhere you switch on a kind, type, or status field. We have shipped major refactors where adding a new event type would have been a nightmare without exhaustive checks — instead, the compiler told us every place we needed to update before we even ran the tests.
Type-Only Imports for Bundle Hygiene
In larger codebases, you start to care about which imports are runtime vs. type-only. A type import does not produce JavaScript at compile time — it is purely a type system concern. Mixing type-only imports with runtime imports leads to bundle bloat (the bundler thinks it needs to include the runtime symbols) and circular import issues (a type-only cycle is fine, but the bundler does not know that).
The fix: use import type explicitly for type-only imports. import type { User } from './types'. The compiler treats this as a hint that the symbol is never used at runtime, allowing it to be erased completely from the output. Modern tooling (esbuild, swc, the TypeScript compiler with isolatedModules) increasingly requires this for correctness. The discipline is small — write type when you mean type. The payoff is real — smaller bundles, cleaner module graphs, and fewer mysterious bundling errors.
Zod or io-ts at the Boundaries
TypeScript's type system is purely a compile-time guarantee. At runtime, the types do not exist. Anywhere your code crosses a system boundary — an HTTP request, a database read, a file system read, a user form submission — you have no compile-time guarantee that the data matches your types. This is where production code goes wrong: the API returns something your code did not expect, you treat the value as your type, and you get a TypeError ten functions deep.
The fix is runtime validation at boundaries. We use Zod (or io-ts in some codebases) to define schemas that produce TypeScript types AND runtime parsers from the same source. The pattern: every API route handler, every database read, every external integration response runs through a Zod parse. If the data does not match the schema, you get a clear error at the boundary — not a confusing TypeError thirty stack frames deep. The runtime cost is negligible. The bug-prevention value is enormous. Make Zod (or equivalent) a non-negotiable in every project.
Result Types Over Throws
JavaScript and TypeScript both make exception handling implicit. Any function can throw. The type system does not tell you which functions throw what errors. The convention in production code is to make errors explicit at the type level: functions that can fail return a Result<T, E> type — typically a discriminated union of { success: true, data: T } and { success: false, error: E }.
The caller has to handle both cases to access the data. The compiler enforces it. Errors become first-class values that flow through the type system instead of invisible side channels. The tradeoff is verbosity — every fallible function returns a wrapped value, every caller has to unwrap it. The payoff is that error handling becomes impossible to forget. We have shipped large codebases where Result types eliminated nearly all "uncaught exception" bugs. The code is more verbose. It is also more correct. Pick correctness.
The Compiler as a Design Partner
The shift in mindset that separates teams that succeed with TypeScript from teams that grind against it is treating the compiler as a design partner. When you find yourself fighting the type system, the bug is usually in your design, not in TypeScript. The error message is telling you something is wrong with the way the code is structured — usually that two values can be confused, or that a state can be reached that should not be representable.
The patterns above — branded types, exhaustive checks, type-only imports, runtime validation at boundaries, Result types — all share a common philosophy. Make impossible states impossible. Push as much correctness as possible into the type system. Trust the compiler when it complains. Refactor toward designs where the type system is your ally instead of your obstacle. This is what production-grade TypeScript actually looks like — and it is the difference between codebases that scale and codebases that collapse under their own weight at 50,000 lines.
Ready to put this into action?
We build the digital infrastructure that turns strategy into revenue. Let's talk about what DRTYLABS can do for your business.
Get in Touch