Every TypeScript codebase eventually reaches a tipping point. Not the “this is amazing, we caught a bug at compile time” moment — the other one. The moment a senior engineer spends an entire afternoon wrestling a conditional generic into submission, and a junior engineer stares at the type error on their screen like it’s written in hieroglyphics. TypeScript has a tax. Not a monetary one — a cognitive, velocity, and complexity tax that compounds silently until your team spends more time fighting the compiler than shipping features.
The trick isn’t avoiding the tax. It’s knowing when you’re paying it and whether you’re getting a return.
“Make it work, make it right, make it fast.” — Kent Beck
That quote applies to types too. Make it work with basic types. Make it right with structural modeling. Resist the urge to make it “clever” with four-level generics.
The Type Complexity Spectrum
Not all type safety is created equal. Think of it as a spectrum with diminishing returns:
| Level | What It Looks Like | ROI | When to Use |
|---|
| Basic | Typed function params, return types | Very high | Always — this is table stakes |
| Structural | Discriminated unions, interfaces | High | State modeling, API responses |
| Branded | Nominal types for domain IDs | Medium-high | IDs that cross module boundaries |
| Inferred | satisfies, const assertions, mapped types | Medium | Configuration, constants |
| Deep Generic | 3+ type params, conditional types | Low-medium | Library code only |
| Type Gymnastics | Template literal parsing, type-level arithmetic | Very low | Almost never in application code |
Everything from Basic through Branded is almost always worth it. Deep Generics are situational — reserved for library authors, not feature developers. Type Gymnastics are a fun puzzle and almost never belong in a production codebase.
Here’s the litmus test for any complex type: Can a mid-level engineer read it in 30 seconds? Does it catch bugs that a unit test wouldn’t? If you answer “no” to both, simplify.
The 80/20 Rule of Type Safety
Here’s the uncomfortable truth: 80% of TypeScript’s bug-prevention value comes from 20% of its features. Basic types on function parameters, discriminated unions for state, and runtime validation at boundaries — that’s the 20% that catches the vast majority of bugs.
The remaining 80% of the type system — mapped types, conditional types, the infer keyword, recursive types — is powerful and occasionally necessary. But in application code, reaching for these features is usually a sign that your data model needs simplifying, not that your types need more layers.
| Feature | Bug Prevention Value | Complexity Cost | Verdict |
|---|
| Typed function params | High | None | Always use |
| Discriminated unions | Very high | Low | Always use |
| Zod/runtime validation at boundaries | Very high | Low | Always use |
| Branded types for IDs | Medium | Low | Use for domain-critical IDs |
| Mapped/conditional types | Low-medium | High | Library code only |
| Template literal types | Low | Very high | Avoid in apps |
When Strict Typing Goes Too Far
I once watched a team spend three weeks building a type-safe routing system that parsed URL parameters at the type level. It was genuinely impressive engineering — 400+ lines of type definitions, inference so deep that IDE autocomplete took 8–12 seconds to resolve. The thing it replaced? A simple useParams<{ id: string }>() call that took zero seconds to understand and zero seconds to compile.
This is the trap. TypeScript rewards cleverness. The type system is expressive enough that you can encode almost any constraint. But “can” doesn’t mean “should.”
Over-Engineered vs. Pragmatic
Here’s the contrast in practice. A type-heavy API client with four generic parameters and conditional return types:
type ApiMethod<
TInput extends z.ZodType,
TOutput extends z.ZodType,
TParams extends Record<string, string> = Record<string, never>,
TQuery extends z.ZodType = z.ZodNever,
> = (
input: z.infer<TInput>,
params: TParams extends Record<string, never> ? never : TParams,
) => Promise<ApiResponse<z.infer<TOutput>>>;
Every consumer needs a PhD in conditional types. Error messages are paragraphs long. New engineers avoid touching anything that uses it. Compare the pragmatic version:
interface ApiResponse<T> {
data: T;
meta?: Record<string, unknown>;
}
async function apiGet<T>(url: string, schema: z.ZodType<T>): Promise<T> {
const res = await fetch(url);
return schema.parse(await res.json());
}
Less type-safe? Marginally. More maintainable? Enormously. Zod validates at runtime. The types stay simple enough for any engineer to follow.
The Pragmatic any (With Guardrails)
Sometimes any is the right call. Not sprinkled throughout — deployed surgically at boundaries where the type system’s cost exceeds its value.
| Scenario | Why any Works | The Guardrail |
|---|
| Middleware wrapping generic handlers | Generics would be absurd | Return type is explicitly typed |
| Third-party library with broken types | You can’t fix their types | Wrap in a typed function |
| Complex JSON transformations | Intermediate shapes don’t matter | Final return type is explicit |
| Prototype/spike code | Speed matters more than safety | Mark with // TODO: type properly |
The rule: any at the boundary, typed at the interface. If a function accepts any, its return type must be explicit. The any is a bridge, not a destination.
Every any should have a comment explaining why. If you can’t articulate the reason, you probably shouldn’t use it. During code review, an uncommented any is an automatic conversation starter.
Branded Types: Where the Line Is
Branded types create nominal typing in a structurally-typed language. They’re excellent for IDs that cross module boundaries — passing a UserId where an AccountId was expected is a real bug category in any system that handles multiple entity types.
But I’ve seen teams brand everything: FirstName, LastName, EmailSubject, LogMessage. When you brand FirstName and LastName, every function that takes a name needs to explicitly construct the branded type. The conversion ceremony adds noise for negligible safety. Nobody has ever shipped a production bug because they passed a first name where a last name was expected.
Brand the types where mixing them up causes data corruption or financial impact. For everything else, a plain string with a descriptive parameter name is fine.
What to Tell Your Team
When engineers ask “how strict should we be?”, give them this framework:
- Type the boundaries. Function parameters, API responses, database results, form inputs. Non-negotiable.
- Use discriminated unions for state. Any time you have a status field with associated data, model it as a union. This prevents more bugs than anything else.
- Validate at runtime where data enters the system. Zod at the API boundary, Zod for forms. The type system can’t protect you from bad data over the wire.
- Let inference handle the rest. If you’re annotating local variables, you’re wasting keystrokes.
- Reach for
any before reaching for a 4-level generic. A targeted any with a typed return is cheaper than a generic tower nobody can debug.
The goal of TypeScript isn’t to encode every possible constraint into the type system. It’s to catch the bugs that matter at the lowest cost to developer experience. Push back on type complexity as aggressively as you push back on code complexity — they’re the same problem.
TypeScript is a tool. Like any tool, it has a cost. The best TypeScript codebases aren’t the ones with the most sophisticated types — they’re the ones where the type complexity is proportional to the domain complexity. Nothing more, nothing less.