Back to Blog
typescriptarchitecturedx

TypeScript at Scale: Where the Type System Stops Helping

June 12, 20259 min read

The first time I worked on a TypeScript codebase that was genuinely big — call it 300k lines of React, a few hundred components, a dozen people committing to it every day — I came away convinced of two things at once. One: I'd never want to maintain something that size in plain JavaScript ever again. Two: a non-trivial amount of the friction my team complained about every week was the type system, not in spite of itself but because of how we'd used it.

Both things are still true. This post is mostly about the second one, because there are already plenty of articles arguing for the first.

What still works

I want to be honest about this part before I start complaining, because the complaints don't land otherwise.

TypeScript is, for me, the single biggest reason large frontend refactors are tractable now. Renaming a prop that lives in 80 components and threading the change through is a five-minute job and a green CI run, not a multi-day audit with grep and prayer. Pulling a field off an API response and watching the compiler surface every place that field is read — every selector, every memoized derivation, every JSX expression — is genuinely one of those things that changes what kinds of changes you're willing to attempt.

The second thing is API contracts. Generating types from an OpenAPI schema (or, increasingly, a Zod schema shared with the backend) and treating the boundary between server and client as a typed surface has saved me from more bugs than I can count. The class of error where someone misspells a field name and the UI silently renders undefined for two weeks before a QA tester catches it — that whole class is essentially gone, and I don't miss it.

And the third is the one nobody mentions in conference talks: intellisense as documentation. When I onboard onto a new part of the codebase, hovering over a function and reading its signature is, three times out of four, all the documentation I need. The same is true in reverse — designing a function signature carefully is itself a form of writing docs that won't go out of date.

That's the case for the defense. Now, the trouble.

The things that don't scale

Roughly three patterns show up over and over again in old, large TypeScript codebases, and they all start out looking like good ideas.

The first is generic types that grow generics inside them. I once worked on a useResource<TData, TError, TVariables, TContext> hook that started with two generics, then grew a third because someone needed to attach a meta object to a mutation, then a fourth because the cache key shape couldn't be expressed any other way. By the time I inherited it, calling the hook required either copying a working call site from somewhere else in the repo, or staring at the definition for ten minutes trying to figure out what slot you were supposed to fill in. The type signature was, technically, correct. It was also unusable.

This is the rule I now try to enforce on myself: if I'm reaching for a fourth generic, the abstraction is wrong. Three is the practical ceiling before the call-site cost outweighs the inference benefit. Past three, you're not building an API anymore, you're building a constraint solver and asking the next developer to be a competent operator of it.

The second pattern is any-creep. The honest version of how it happens: someone is fixing a bug under deadline, the types in some library don't quite match its runtime behavior, they slap an as any on it with a // TODO: fix types comment, the PR ships, the comment never gets revisited. Six months later there are forty of those, and the // TODO part has rotted into something more like archaeology.

We tried the obvious fix — a lint rule banning any — and it backfired the way you'd expect. People wrote as unknown as SomeType, which is any with extra ceremony. Or worse, they reached for @ts-ignore, which is any with a brick over the warning light. The lint rule treated the symptom and the team learned to route around it.

What actually worked, eventually, was making the type ceiling of the codebase visible. We started tracking the count of as casts and @ts-ignore directives per package, and surfaced them on a dashboard somebody actually looked at. The number didn't need to go to zero. It needed to stop growing. That framing — "let's not make it worse" rather than "let's purge all of this" — turned out to be the version that stuck.

I also want to admit something here because it's relevant. There's a third-party library we depend on that has types I would charitably describe as wrong, and I once spent two days writing a 400-line module-augmentation patch file to fix them at the type level. It works. Nobody has touched it since I shipped it. I am not proud of it, but I'm also not going to delete it, because the alternative is as casts at every call site, which is the worse problem. Sometimes the right answer is just "this is the shape of the world, write the patch."

The third pattern is the discriminated union that grows beyond what a human can hold in their head. Discriminated unions are one of TypeScript's best features — when you have three or four variants of a thing, modeling them as a tagged union with a kind field gives you the exhaustiveness check, the narrowing in switch, all of it. But I've watched these grow. A notification type that started as { kind: 'info' } | { kind: 'warning' } | { kind: 'error' } accreted variants as the product grew: 'inline-error', 'modal-error', 'toast-error', 'banner-error', and so on, until the union had fourteen entries and every consumer was a switch statement that scrolled off the screen.

The fix here is not better types — it's recognizing that the type has stopped being a model and started being a junk drawer. When the union hits, say, six or seven variants, that's a signal that the underlying concept needs to be split. In the notification case, we eventually pulled it apart into three smaller, more honest types: severity, surface, and lifetime. Each had two or three variants. The product still rendered all fourteen combinations, but the type system stopped being the place where every product decision had to be enumerated.

Rules I actually follow now

I'm wary of "rules of thumb" posts because they're usually too clean to survive contact with a real codebase. These are the ones that have held up across more than one team for me, though.

Narrow at the boundary, not inside. If data is coming from outside the app — an API, localStorage, a URL param, anything — parse it once at the edge with something like Zod or a custom validator, and from that point on, trust the type. Don't sprinkle if (typeof x === 'string') checks twelve layers deep into your component tree. Past the boundary, the typed value is the contract; inside the boundary, code should read like there's no doubt.

Type errors are better than runtime errors, but not religiously. I've seen people write code that's a maze of conditional types and infer clauses to express something a comment could have expressed in eight words. The type system is a tool, not a creed. If proving the property statically is going to take a day of someone else's reading time later, write the simpler types and the runtime assertion instead.

as is admitting defeat, but admitting defeat is sometimes correct. I am not a purist about this. Every codebase has a few as casts that exist because something downstream — a library, a third-party SDK, a generated type — is wrong, and fixing the upstream type is more cost than benefit. What I care about is that we know which casts are real escape hatches versus accidental ones. Comments help. So does a convention where every as cast has a comment that names the specific reason, in one line, without ceremony.

Don't make types do work the runtime should do. I've seen elaborate type gymnastics built to enforce, at compile time, that two arrays are the same length, or that a string matches a regex, or that a record has exactly these keys and no others. Sometimes that's a legitimate need. More often it's a person who learned that TypeScript can express these things and forgot to ask whether it should. A .length check at runtime is one line and obvious. A TupleOfLength<T, N> type is six lines of conditional types and a permanent tax on every reader.

What's actually hard

The thing I've come to think is genuinely hard, the kind of hard that doesn't have a clever solution, is type drift in a large team over time. People come and go. Conventions shift. The first person who established the type idioms is now at another company, and the second-generation contributors learned by mimicry from the codebase, not from first principles. Five years in, you have a codebase where every section has its own micro-dialect of TypeScript — one team that overuses interface, one team that overuses type, one team with elaborate utility types, one team that's secretly written everything as any in trench coats.

There's no magic fix for this. The things that have helped, in my experience: a small types package that defines the shared primitives (Email, Uuid, ISO8601, that kind of thing) so people stop reinventing them; a tsconfig set up with strict: true from day one (retrofitting strict mode on an existing codebase is its own particular nightmare); and someone — usually just one person — who cares enough to do quiet ongoing cleanup. Not a "TypeScript Tsar." Just somebody who'll spend an hour a week deleting the dead utility types nobody uses.

That last bit is, I think, the part that doesn't show up in the conference talks. The type system at scale isn't a technology problem in the end. It's a maintenance problem dressed up as a technology problem, and it responds to maintenance attention the way every other piece of a codebase does.

I'd still pick TypeScript again. I just stopped expecting it to do work that no tool can do for you.