Most Advanced TypeScript Isn't Worth What It Costs

Sep 03, 2026
10 min read
Most Advanced TypeScript Isn't Worth What It Costs

Let me start with an admission, because it shapes everything below: I'm not especially strong at advanced TypeScript. Conditional types nested three deep, recursive template literal parsers, the kind of thing that shows up in a library's internals and gets applauded on Twitter — I read that code slowly, and I don't enjoy writing it.

For a while I treated that as a gap to close. Now I mostly treat it as a constraint worth designing around, and I think that's the more useful position. Not because type-level programming isn't impressive. Because on an application codebase, a type that only one person on the team can modify is a liability wearing the costume of rigor.

The earlier TypeScript article covered the patterns I reach for constantly — typing the API boundary, discriminated unions for async state, inference at the router. This one is about the question that comes after you know the patterns: how much type complexity should a given piece of code carry, and how do you tell when you've overshot?

Types have a running cost

The pitch for TypeScript is that types catch bugs. True, and I'd never go back. But the framing hides something: a type isn't a one-time purchase. It's a thing every future reader has to understand before they can safely change the code underneath it.

So each type has a cost, and it shows up in three places. Someone has to read it to understand the contract. Someone has to modify it when requirements move. And when it goes wrong, someone has to decode the error message — which, for a sufficiently clever type, is a wall of text that names none of the things you actually wrote.

That last one deserves more weight than it usually gets. A type that produces an unreadable error at the call site has partly defeated its own purpose. The whole point was to tell someone they made a mistake. If the message needs its own investigation, you haven't caught the bug so much as relocated it.

The value of a type is not how much it proves. It's how much it proves per unit of understanding it demands.

That ratio is the thing I actually optimize for now, and it explains why my list of "worth it" is short.

What earns its keep

Discriminated unions, everywhere. This is the highest-value construct in application TypeScript and it's barely "advanced" at all:

type PolicyRequest = | { status: 'idle' } | { status: 'loading' } | { status: 'error'; message: string } | { status: 'success'; policies: Policy[] };

Four lines, and an entire class of bug is now unrepresentable — you cannot read policies without first proving you're in the success branch. The compiler narrows it for you, the errors are legible, and any developer can extend it without a conversation. Cheap to read, expensive bugs prevented. That's the ratio you want.

Branded types, but only where mixups are real. If your codebase passes around several kinds of ID as bare strings and you've actually shipped a bug from swapping two of them, this is worth it:

type PolicyId = string & { readonly __brand: 'PolicyId' }; type CustomerId = string & { readonly __brand: 'CustomerId' };

Note the condition. If you've actually shipped that bug. Branding every primitive in the app because it's theoretically safer is how you end up with casts scattered everywhere and a team quietly annoyed at you.

satisfies instead of annotation, when you want both. This one is underused and it's almost free:

const routePermissions = { policies: ['read', 'write'], billing: ['read'], } satisfies Record<string, Permission[]>;

You get the constraint checked and the literal types preserved, so routePermissions.billing is ['read'] rather than a widened Permission[]. No cleverness required, real inference gained.

Generics on components, one parameter deep. A typed DataTable<TRow> that infers the row type from the data you pass it is genuinely good. Two or three interdependent type parameters with constraints referencing each other is where it stops paying, in my experience — the signature becomes the thing people copy-paste rather than read.

What usually doesn't

Type-level logic that reimplements runtime logic. Deep conditional types that compute a shape based on five flags. Recursive types that parse a string format into a structure. These are legitimately impressive and they belong in libraries, where a small number of maintainers absorb the complexity so thousands of users don't have to. In application code the arithmetic is inverted: your whole team pays, nobody outside benefits, and the requirements will change next quarter anyway.

Types that exist to avoid a small runtime check. Sometimes the honest answer is a validation at the boundary and a plain type afterward:

const policy = policySchema.parse(response.data);

You've now got a real guarantee — checked against actual data, not just asserted about it — and the type downstream is boring. I'd take that over an elaborate type that describes what the server should send, every time. The type system can't see your API. A parser can.

Generic abstractions built for one call site. The rule I'd apply here is the same one that applies to component abstractions: don't generalize until you have the second case in front of you. A generic hook with three type parameters serving exactly one consumer is a puzzle you built for yourself.

The test I actually use

When I'm not sure whether a type has gone too far, I ask two things.

First: can a mid-level developer on this team change the code this type guards, without asking me? If the honest answer is no, the type has become a bottleneck with my name on it. That's not safety — it's a bus factor problem I introduced on purpose.

Second: what does the error look like when someone gets it wrong? I'll take a slightly weaker type with a clear failure message over a stronger one that produces forty lines of inference noise. The error message is the user interface of a type, and it deserves the same consideration as any other interface.

Neither test involves how much the type proves. That's deliberate.

Where I've landed

I used to think being weak at type-level programming meant I'd eventually hit a ceiling in TypeScript. What actually happened is that the ceiling turned out to be somewhere I don't need to go very often — and that most of the safety I care about comes from a handful of unglamorous constructs applied consistently at the right places, mostly at the edges where data enters the app.

The interesting decisions aren't in the type system at all. They're about where you put the boundary, what you validate, and which mistakes you've decided are worth making impossible. Once those are right, the types tend to stay simple on their own. When I find myself writing something genuinely gnarly, it's usually a sign the design underneath it is doing something it shouldn't.

I'm aware this is the position of someone who isn't a type wizard, and I'd take the counter-argument seriously. So if you've got a case where deep type-level machinery genuinely paid for itself in an application — not a library — I want to see it. That's the example that would move me.

Telegram

More than a blog post

I share frontend news and the reasoning behind it throughout the day. Pick the language that feels natural to you.

Need to discuss your project? Get in touch.