TypeScript: from friend to nightmare in one step
Why types protect you, why they trap you, and what happens when type-level programming starts dictating your architecture.
TypeScript is a friend until it suddenly becomes a nightmare. One step.
At first TypeScript feels like pure benefit. You brand variables, describe contracts, and the compiler protects you from stupid mistakes. In a large project with shared types you actually start understanding what is allowed and what is not. This is the real power of the language.
When types turn against you
Problems begin the moment you try to change something that already exists. Suddenly a small edit produces dozens of red errors. The desire to close the editor and go make tea becomes very strong.
JavaScript already requires discipline. TypeScript raises the price of that discipline significantly. A large part of attention shifts from business logic to generics, conditional types and mapped types. You stop thinking about the feature and start thinking about how to satisfy the type system.
typescript// A type factory that felt clever at the time type ApiResponse<T, E extends string = "default"> = E extends "paginated" ? { data: T[]; total: number; page: number } : E extends "nullable" ? { data: T | null } : { data: T } // Changing the business model means rewriting the factory // and hunting down every place it was used
Type-level programming as a second language
At some point type-level programming stops being a tool and becomes a cage. The code looks “correct”, but it becomes fragile and expensive to change.
The type language is essentially a pure functional language living inside TypeScript. No loops — only recursion. No mutations — only new types. Writing serious logic in this style is mentally heavy, especially when you are already tired.
typescript// Recursive conditional types — elegant until you need to debug them type DeepReadonly<T> = T extends (infer U)[] ? ReadonlyArray<DeepReadonly<U>> : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T // The compiler error when this breaks is never obvious
This is where architectural overengineering starts. Developers try to make types perfect and account for every possible case. Later, changing one field in the business logic turns half of the project red, because a complex type factory written months ago no longer holds. Types begin to dictate the architecture of the application, although it should be the other way around.
Writing everything with any is not a solution either
The opposite extreme is also bad. Covering everything with any simply disables the tool. A strict linter will constantly hit your hands, and you will be forced to use unknown and do proper narrowing.
typescript// Bad — silences the error, hides the problem function parse(input: any) { return input.data.value } // Good — forces you to handle what you don’t know function parse(input: unknown) { if ( typeof input === "object" && input !== null && "data" in input && typeof (input as { data: unknown }).data === "object" ) { // now you can safely access .data } }
I felt this clearly on Web. After enabling a stricter setup I got around 2000 errors. The number itself was shocking. I did not fix all of them. I switched to a more realistic configuration, but made a note: next time the linter and strict mode should be connected from the very beginning.
What I actually learned
Types are excellent servants and terrible masters. They should protect the architecture, not define it. The moment you start designing the system around the type system instead of the other way around, you are already in trouble.
Good TypeScript is not the most complex types. It is the simplest types that still give you enough safety to move fast without fear.
typescript// Overengineered type UserState<S extends "loading" | "ready" | "error"> = S extends "ready" ? { data: User; error: null } : S extends "error" ? { data: null; error: string } : { data: null; error: null } // Simpler — and just as safe for 99% of cases type UserState = | { status: "loading" } | { status: "ready"; data: User } | { status: "error"; error: string }