The TypeScript patterns that survive a big codebase

Not the clever type-level tricks. The handful of ordinary habits that keep holding up when the codebase gets large, plus the bugs I shipped that each one would have caught.

TypeScript's type system is expressive enough that you can spend a lot of time being clever in it, and almost none of that cleverness is what keeps a large codebase honest. The patterns that actually hold up are unglamorous. Each of the ones below corresponds to a bug I shipped and then had to go back and fix.

Discriminated unions instead of optional fields

The instinct when a shape varies is to add optional fields, so a type acquires four or five question marks and every consumer has to guess which combinations are real. Nothing stops you constructing a value with two mutually exclusive fields both set, because the type permits it.

A union on a literal tag fixes this. Each variant carries only the fields that make sense for it, and the compiler narrows once you switch on the tag. Illegal combinations stop being a convention you maintain and become unrepresentable.

The content model behind this blog works exactly this way: a block is one of a small set of kinds, each with its own fields, and the renderer switches on the kind. Adding a new kind is one case in one switch, and the compiler tells me every place that needs updating. That last property is the one that matters at scale, because it converts 'remember to update all the call sites' into a build error.

The goal is not describing your data accurately. It is making the wrong shape impossible to construct.

Errors belong in the return type

A function that throws has a signature that lies about what it does. The type says it returns a value; the runtime says it might not. Every caller has to know from documentation or from being burned.

Returning a result that explicitly represents success or failure puts that back in the signature. Callers cannot reach the value without acknowledging the failure case, which is precisely the thing you want enforced.

The bug that taught me this: a fetch failure was rendering as an empty state. To the user, 'we could not load your data' and 'you have no data yet' looked identical, and the second one came with onboarding instructions telling them to set up something they had already set up. The types allowed a failure to be indistinguishable from an empty success, so eventually it was.

Do not let a negative result be cached like a positive one

This one is specific but the class is common. I cached lookups against an external API, including the outcome where a record did not exist. Reasonable, until that API rate-limited me. The rate-limit response was interpreted as a not-found and cached as 'this does not exist', which is a temporary failure promoted to a permanent fact.

The type-level lesson is that 'not found' and 'could not determine' are different values and should never collapse into the same one. When they share a representation, the code cannot distinguish a fact from a failure, and the failure is the one that gets cached forever.

unknown at every boundary

Anything crossing into your program from outside, an API response, a config file, a URL parameter, is unknown until validated. Declaring a fetch result as your expected interface is a claim you have not checked, and it holds until the day the API changes shape and you get a crash somewhere far away from the boundary that was actually wrong.

Parse at the edge, validate there, and let everything inside work with types that are true. The error then names the boundary rather than appearing five layers deep with no indication of where the bad data entered.

This is also where the security bugs live. Two of mine were exactly this shape: a sanitiser that missed a scheme-based bypass, and user-controlled text interpolated into an email without escaping. In both cases the value had been treated as trustworthy the moment it entered, and nothing downstream questioned it again.

Split types by runtime, not by convenience

Modern applications run parts of themselves in environments with different capabilities. Code shared between an edge runtime and a full server runtime cannot assume the same libraries exist, and importing a database client into something running at the edge fails at build time if you are lucky and at runtime if you are not.

I hit this with an authentication config that had grown to include both the edge-safe parts and the database-backed parts in one module. Splitting it into two, with the edge-safe piece importing nothing that cannot run there, made the constraint visible in the module graph instead of being a rule people had to remember.

If a constraint only exists in someone's head, it is already broken somewhere you have not looked yet.

Never let time come from wherever is convenient

A feature of mine calculated urgency from the server's clock, which meant the behaviour changed depending on the deployment region's timezone. The types were fine. A date is a date. But 'which clock' was never expressed anywhere, so it defaulted to whichever one happened to be nearby.

Anything user-facing that depends on time needs the timezone in the type or the function signature, so choosing it is a decision rather than an accident. The related bug in the same project: a scheduled email went out with links pointing at localhost, because the base URL also came from ambient environment rather than explicit configuration.

The pattern behind all of these

None of this requires an advanced type feature. Every bug above was fixable with a union, an explicit return type, or a module split. What they have in common is that the type system was being used to describe what the code did rather than to constrain what it was allowed to do, and the second one is where the value is.