TypeScript at Scale: Where the Type System Stops Helping
The first big TypeScript codebase I worked on — 300k lines of React, a dozen people committing daily — left me with two convictions. One: I'd never maintain something that size in plain JavaScript again. Two: a lot of the friction my team complained about every week was the type system itself, because of how we'd used it. The first one is well-covered. This is about the second.
The generics that ate themselves
I once inherited a useResource<TData, TError, TVariables, TContext> hook. It started with two generics. Then someone needed to attach meta to a mutation, so three. Then the cache key shape couldn't be expressed any other way, so four. Calling it required either copying a working call site from elsewhere in the repo, or staring at the definition for ten minutes trying to figure out which slot you were filling in.
The rule I now enforce on myself: if I'm reaching for a fourth generic, the abstraction is wrong. Past three you're not building an API, you're building a constraint solver and asking the next developer to be a competent operator of it.
The any-creep dashboard
How it always happens: someone fixing a bug under deadline, library types don't match runtime, they slap as any with a // TODO, PR ships, comment never gets revisited. Six months later, forty of those, and the // TODO part has rotted into archaeology.
The obvious fix — a lint rule banning any — backfired. People wrote as unknown as SomeType, which is any with extra ceremony. Or @ts-ignore, which is any with a brick over the warning light. The team learned to route around the rule.
What worked was making the ceiling visible. We tracked as casts and @ts-ignore directives per package on a dashboard somebody actually looked at. The number didn't need to hit zero — it needed to stop growing. "Let's not make it worse" stuck where "let's purge all of this" never did.
The junk-drawer union
Tagged unions are one of TypeScript's best features when you have three or four variants. But I've watched them grow. A notification type that started as { kind: 'info' } | { kind: 'warning' } | { kind: 'error' } accreted 'inline-error', 'modal-error', 'toast-error', 'banner-error', on and on, until the union had fourteen entries and every consumer was a switch that scrolled off the screen.
The fix isn't better types — it's recognizing the type has stopped being a model and become a junk drawer. We pulled it apart into three smaller types: severity, surface, lifetime. Two or three variants each. The product still rendered all fourteen combinations. The type system stopped being the place where every product decision had to be enumerated.
What I do now
Narrow at the boundary, not inside. Parse data once at the edge with Zod, then trust the type. Don't sprinkle if (typeof x === 'string') twelve layers deep.
Every codebase has a few as casts where the upstream is wrong and fixing it isn't worth it. What matters is knowing which casts are real escape hatches and which are accidents — a one-line comment naming the reason does most of the work.
Don't make types do work the runtime should do. A .length check is one line. A TupleOfLength<T, N> is six lines of conditional types and a permanent tax on every reader.
The type system at scale isn't a technology problem. It's a maintenance problem dressed up as one. I'd still pick TypeScript again. I just stopped expecting it to do work no tool can do for you.