Skip to main content
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. TypeScript code on a screen
“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:
LevelWhat It Looks LikeROIWhen to Use
BasicTyped function params, return typesVery highAlways — this is table stakes
StructuralDiscriminated unions, interfacesHighState modeling, API responses
BrandedNominal types for domain IDsMedium-highIDs that cross module boundaries
Inferredsatisfies, const assertions, mapped typesMediumConfiguration, constants
Deep Generic3+ type params, conditional typesLow-mediumLibrary code only
Type GymnasticsTemplate literal parsing, type-level arithmeticVery lowAlmost 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.
FeatureBug Prevention ValueComplexity CostVerdict
Typed function paramsHighNoneAlways use
Discriminated unionsVery highLowAlways use
Zod/runtime validation at boundariesVery highLowAlways use
Branded types for IDsMediumLowUse for domain-critical IDs
Mapped/conditional typesLow-mediumHighLibrary code only
Template literal typesLowVery highAvoid 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.
ScenarioWhy any WorksThe Guardrail
Middleware wrapping generic handlersGenerics would be absurdReturn type is explicitly typed
Third-party library with broken typesYou can’t fix their typesWrap in a typed function
Complex JSON transformationsIntermediate shapes don’t matterFinal return type is explicit
Prototype/spike codeSpeed matters more than safetyMark 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:
  1. Type the boundaries. Function parameters, API responses, database results, form inputs. Non-negotiable.
  2. 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.
  3. 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.
  4. Let inference handle the rest. If you’re annotating local variables, you’re wasting keystrokes.
  5. 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.