
TypeScript Types Have Gone Too Far (And What to Do About It)
April 11, 2026
TypeScript type complexity has become a status symbol. I have reviewed PRs where the type definition was longer than the function it described. I have watched senior engineers spend an afternoon wrestling a conditional mapped type into submission when string would have been fine. Types exist to catch bugs, not to prove you read the handbook.
The Type Gymnastics Problem#
Open any popular TypeScript utility library and count the lines of type code versus runtime code. I did this recently on a project with 200+ files. The type-level logic outweighed the actual business logic 3:1. That is not a flex. That is a maintenance debt nobody wants to talk about.
Here is a real pattern I found in a production codebase. The developer wanted to extract nested keys from an object type. Noble goal. Horrific execution.
type DeepKeyOf<T> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? `${K}` | `${K}.${DeepKeyOf<T[K]>}`
: `${K}`;
}[keyof T & string]
: never;
type NestedPick<T, K extends DeepKeyOf<T>> =
K extends `${infer Head}.${infer Rest}`
? Head extends keyof T
? Rest extends DeepKeyOf<T[Head]>
? { [P in Head]: NestedPick<T[Head], Rest> }
: never
: never
: K extends keyof T
? { [P in K]: T[P] }
: never;
Twenty lines of types for a function that could have taken a string path and returned unknown. The person who wrote it left the company. Nobody on the team could modify it. The types became a wall, not a safety net.
I see this pattern constantly. A developer discovers template literal types or conditional mapped types and builds a cathedral. The cathedral looks impressive in a tweet. Then a junior developer tries to add a field and gets a 400-character error message that scrolls off the terminal.
The worst part is what happens when something goes wrong. TypeScript error messages for deeply nested conditional types are borderline unreadable. I watched a developer paste a TS error into ChatGPT because the error itself was 12 lines of nested angle brackets. That is not a developer experience win.
Warning: If your type error message is longer than the function body, you have optimized for the compiler at the expense of your team.
When Complex Types Actually Help#
I am not anti-type. I write strict TypeScript every day. Complex types earn their keep in exactly three places.
- Library boundaries. If you maintain an SDK or framework consumed by hundreds of developers, investing in precise types pays off. Zod, tRPC, and Drizzle do this well. Their users never see the internals.
- Serialization layers. API response types, database row types, form schema types. These are contracts between systems. Get them wrong and bugs hide for weeks.
- State machines. Discriminated unions that model explicit states prevent entire categories of null-check bugs. Model
Loading | Error | Successas separate types and the compiler catches impossible states for you.
Notice what is not on that list. Application-level business logic. Your handleSubmit function does not need a four-level conditional type. A simple interface with readable property names does the job better.
The Readability Test#
I have a rule. If a mid-level developer on your team cannot read the type and understand what it accepts within 30 seconds, simplify it. Not because mid-level developers are not smart. Because code is read ten times more than it is written, and your type definitions are code.
The TypeScript community has developed a culture where complexity signals competence. Solving type puzzles on Type Challenges is genuinely fun. I have done it. But type puzzles are practice, not production patterns.
Compare these two approaches to typing an event handler map.
type EventMap<T extends Record<string, unknown>> = {
[K in keyof T as `on${Capitalize<K & string>}`]: (
payload: T[K] extends (...args: infer A) => unknown ? A[0] : T[K]
) => void;
};
interface AppEvents {
onUserLogin: (user: { id: string; email: string }) => void;
onCartUpdate: (items: CartItem[]) => void;
onError: (error: { code: number; message: string }) => void;
}
The second version is longer in lines. It is also readable by every person on your team, searchable with grep, and produces clear error messages. The first version saves 5 lines and costs hours of debugging when something breaks.
I started applying this test to every PR I review. Can the person after me understand this type without reading the implementation? If the answer is no, I ask them to flatten it. The pushback is always "but this is more type-safe." Type-safe and unreadable is not a net positive.
Practical Patterns That Scale#
After years of TypeScript in production monorepos and AI-generated codebases, I have landed on a small set of patterns that give 90% of the safety with 10% of the complexity.
- Discriminated unions over generics. Model your states explicitly.
type Result = Success | FailurebeatsResult<T, E>in most application code because the variants are visible at the call site. satisfiesoveras. TypeScript 4.9 gave ussatisfiesand it changed how I write config objects. It checks the shape without widening the type. Use it everywhere you used to reach foras const.Record<string, T>over mapped types. Unless you need computed property names from a union, a simple Record is clearer and the error messages are shorter.- Explicit return types on public functions. Do not rely on inference at module boundaries. Write the return type. It documents intent and catches refactoring mistakes before they spread.
unknownoverany. This one is non-negotiable.anyturns off the compiler.unknownforces you to narrow. The extra 2 lines of type narrowing prevent real bugs.
// Discriminated union: explicit, greppable, readable
type ApiResponse =
| { status: 'ok'; data: User[] }
| { status: 'error'; code: number; message: string }
| { status: 'loading' };
// satisfies: type-safe config without widening
const routes = {
home: '/',
blog: '/blog',
contact: '/contact',
} satisfies Record<string, string>;
// Explicit return type at the boundary
export function parseUser(raw: unknown): User {
// narrow and validate here
}
The Rule I Follow#
Every type I write must pass one question. Does this type prevent a bug that would otherwise reach production? If the answer is no, the type is decoration. Decoration has a cost. It slows onboarding, bloats diffs, and makes refactoring harder.
I have seen teams migrate to TypeScript and immediately create a types/ folder with 2,000 lines of generic utility types "for the future." Six months later, most of those types were unused or wrapped around simple string values. The types became the project's junk drawer.
Start with the simplest type that catches real mistakes. Add complexity only when a bug makes it through. That is the entire philosophy. You do not need a type system that models every possible state of the universe. You need one that stops the three mistakes your team actually makes.
The Type Complexity Curve
TypeScript is the best thing that happened to JavaScript. Overengineered types are the worst thing that happened to TypeScript. Keep your types boring, your interfaces explicit, and your generics shallow. Your team will thank you when they can actually read the code.
TL;DR: Write types that prevent production bugs. Delete types that exist to impress. If a mid-level developer cannot read it in 30 seconds, simplify it. Discriminated unions,satisfies, explicit return types, andunknowncover 90% of real-world needs.