When to stop
Type-level cleverness costs compile time, kills go-to-definition, and confuses future you. The honest case for boring types.
You can do almost anything with the type system. That doesn't mean you should. The same conditional-mapped-recursive trick that lets a router library type its paths perfectly can, in your app code, become a type that takes 800ms to resolve, breaks the editor's go-to-definition, and stumps the next person on the team. This last lesson is about the discipline that separates capable from load-bearing-clever.
What clever types cost
Every elaborate type carries hidden bills. They're easy to ignore while you're proud of the design and impossible to ignore six months later.
Compile time. A heavy generic helper that uses recursion, distribution, and template parsing can run thousands of instantiations per call site. With enough of them, your tsc time doubles, your editor's IntelliSense lags by half a second, and CI gets slower for everyone — including the person who didn't write the type.
Editor experience. Hovering a value should show a useful type. A clever type often shows itself — the recursion expanded, the conditionals unresolved, twenty lines of generic noise instead of User. The reader can't see the shape they're working with.
Error messages. When a clever type rejects a value, the error often points to the deepest recursive call rather than the surface mistake. Type { id: "..." } is not assignable to type Path<R, "users", { id: never } & { ... }>. The user can't act on that.
Future you. Clever types are write-once-read-never. Three months in, you'll re-derive the logic from scratch. Six months in, you'll have to ask whether the cleverness is still earning its keep — and you might find it never did.
// A path-walking type that returns the value at any deeply nested key
type DeepGet<T, P extends string> =
P extends `${infer K}.${infer Rest}`
? K extends keyof T
? DeepGet<T[K], Rest>
: never
: P extends keyof T
? T[P]
: never;
// At the call site:
const v = pick(state, "user.profile.address.line1");DeepGet is impressive. It also locks future readers into your type-level mental model just to follow pick. And the moment a key is dynamic — coming from a config file or user input — the type collapses to string, undoing the value.
The explicit alternative
The other path is plain. A small handful of explicit types and a function that takes them straight.
type AddressLine = string;
function getAddressLine1(state: State): AddressLine {
return state.user.profile.address.line1;
}
const v = getAddressLine1(state);The function name carries the meaning a clever type was trying to bottle. Hover-on-call shows AddressLine. The reader doesn't have to evaluate any conditional in their head — the path is visible right where the value is read.
This isn't anti-types. It's types-where-they-help. The explicit version still gets full type safety; it just doesn't try to derive the type from a string. When the path is hard-coded anyway, deriving it adds zero safety and a lot of cognitive overhead.
When you're tempted by a type-level computation, ask: could a small named function do this with no generics? If yes, the function almost always wins on readability.
When complexity earns its place
Some places genuinely need the type-level machinery from this course. Three signals that complexity is paying off:
A library API where every consumer benefits. A router library's path-typed useParams saves every user a manual annotation. The cost is paid once by the library author; the value is paid back across thousands of call sites.
The data is genuinely shaped by a string at compile time. SQL query builders, GraphQL clients, route definitions — places where the string itself is the type contract. A typed string parser earns its complexity because the alternative is hand-syncing two declarations.
The mistake the type prevents is real and frequent. Distinguishing money-as-cents from money-as-dollars at the type level is worth a branded type wrapper because the bug it prevents costs real money. A type that prevents an impossible misuse you've never seen isn't worth the same.
In every case, the test is: does this type pay back its cost across many uses? If only one or two callers benefit, a function or an explicit annotation is almost always cheaper.
A type whose error message reads type instantiation is excessively deep isn't a victory. It means the design is fighting the compiler. Step back and ask whether a plainer shape could carry the same guarantee.
A few rules of thumb
Not laws — defaults to start from when you're deciding how clever to be.
- App code: no recursive types. If you find yourself writing one, write a function instead.
- Library code: yes, but pay attention. Measure compile time after big additions. The lib
tsc --extendedDiagnosticsflag prints instantiation counts. - Generics with three or more parameters need a comment. Future readers can't reverse-engineer intent from
<T, K, U extends Foo<T, K>>alone. - Prefer named types over inline ones. A reusable
type FormErrors<T>is better than four copies of{ [K in keyof T]?: string }. - Avoid clever types in widely-used helpers. A utility called by every component is the worst place for a 200ms type. Optimize for the common path.
- Trust the reader less than yourself. You wrote it; you understand it. They didn't.
The best types are the ones you don't notice. They catch bugs, hover sensibly, and never make anyone scroll. Aim there.
Try it yourself
get(state, "user.profile.name") returns the right type. What's the better default?