A clever type that nobody else can read is technical debt with a smile. The syntax is impressive, the IDE shows the right values, and six months later the original author has rotated off the team and the type is untouchable.

This article is about the discipline of not using every TypeScript feature you know. It's the senior part of working with the language.

Complex Types Should Pay Rent

Every type in your codebase has a cost: someone has to read it, someone has to refactor around it, the compiler has to evaluate it on every save. A complex type pays rent when it prevents a bug class that simpler types could not. It costs you when it just shows off.

TypeScript
// pays rent — discriminated union prevents impossible UI states
type RequestState<T> =
  | { kind: 'idle' }
  | { kind: 'loading' }
  | { kind: 'loaded'; data: T }
  | { kind: 'error'; error: Error };

// does not pay rent — clever, but a plain type would do
type IsPaid<S extends string> = S extends `${infer P}aid` ? P extends 'p' ? true : false : false;

The first one earns its place. The second is the same runtime behavior as s === 'paid' with extra cost.

Prefer Boring Types Near Business Logic

Business logic is where the next developer will spend the most time. Make their reading easy. Save the clever types for infrastructure code where they're written once and read rarely.

TypeScript src/orders/can-cancel.ts
type Order = {
  status: 'draft' | 'paid' | 'shipped' | 'delivered';
  paidAt: Date | null;
};

function canCancel(order: Order): boolean {
  if (order.status === 'delivered') return false;
  if (order.status === 'shipped' && order.paidAt && hoursSince(order.paidAt) > 24) {
    return false;
  }
  return true;
}

Five lines, one union, one discriminated check. Anyone reading this in two years can change a rule without unwinding type machinery first. That's the goal.

Hide Advanced Types Behind APIs

When you do need clever types — for an API client, a state machine, a form library — wrap them so the consumers of the abstraction never see the complexity.

TypeScript src/api/client.ts
// the messy generic stuff lives here, in one file
function get<T>(url: string, schema: ZodSchema<T>): Promise<T> { /* ... */ }

// every other file just calls it
const user = await get('/api/me', UserSchema);

The generic + schema + inference machinery lives in client.ts. Every consumer sees a typed call returning User. The complexity exists once, behind a clean surface.

Three Questions Before Adding A Clever Type

A useful filter to apply before committing the next clever type:

  1. Does it prevent a real bug class? Or does it just feel safer?
  2. Will the next reader follow it without effort? If they need to hover, scroll, and Google, the answer is no.
  3. Could a simple type achieve the same outcome? A union of strings often beats a mapped conditional template literal type.

If you answer "no" to two of those, simplify. The cleverest type is rarely the most useful one.

Inline road sign infographic showing a STOP sign in the center and three numbered question cards below: Does it prevent a real bug? Can the next reader follow it? Could a simple type do the same?
Three questions before adding the next clever type. If two are no, simplify.

Review Types Like Production Code

Types are code. They get reviewed, refactored, and rotted. Treat them with the same care.

In code review, ask: "what bug does this prevent that a simpler type wouldn't?" If the answer is "it's more elegant" or "it's more correct in theory," push back gently. Elegance and theoretical correctness are not features unless they ship safer software.

A useful team norm: any type that's longer than 10 lines or uses three or more conditional/mapped operators needs a comment explaining what bug it prevents. If you can't write that comment, you can't justify the type.

When Overengineering Is Already In The Codebase

Existing clever types are harder to remove than to add. The pragmatic move is usually:

  1. Don't extend them. New code uses simpler types instead.
  2. Replace the consumer surface first. Wrap the clever type in a simpler one and migrate calls over.
  3. Delete when nothing references the original. No big-bang refactor.

This is the same playbook as removing a dependency: gradual, safe, no heroics.

Pro Tips

  1. Keep types short near domain logic. Save the clever ones for infra.
  2. Hide complexity behind APIs. One messy file, many clean consumers.
  3. Comment any type over 10 lines — explain what bug class it prevents.
  4. Avoid type-level computation. It's slow to compile and slow to read.
  5. Treat type review like code review. "Does this earn its rent?" is a fair question.

Final Tips

The best TypeScript codebases I've worked in had unremarkable types. Mostly unions, mostly utility-derived shapes, mostly satisfies for configs. The boring approach onboarded new engineers in a week and let the team move fast for years.

The codebases I've struggled most in had brilliant type machinery. Reading a function meant reading three type files first. Refactoring meant rewriting types alongside code. The cleverness was real and so was the cost.

Use TypeScript to make code easier to change. If a type makes it harder, that type is the bug.

Good luck — and may you write fewer types that earn more rent 👊