Topic 10.6
When Types Get Too Clever
In one line
Type-level code can check a lot, but it can also be unreadable, slow the compiler, hit depth limits and produce error messages nobody understands. Learn to test your types, use @ts-expect-error correctly, and stop at the right moment.
Think of it like this
A Swiss Army knife with 60 tools. It looks impressive, but when you need to open a letter you can't find the right blade and you cut your thumb. A simple knife that everyone can use beats a clever one only its maker understands. Types are tools for your teammates. A type that nobody can read, or that takes ten seconds to compute, has stopped helping.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Instantiation
- Filling in a generic type's parameters to get a real type. Each one costs a little work for the checker.
- Tail recursion
- A type that calls itself as its very last step, so the compiler can loop instead of stacking calls.
- Type test
- A line that only compiles when a type is what you claim it is.
- `Equal<A, B>`
- A helper that is
trueonly when two types are identical, not just assignable. - `@ts-expect-error`
- A comment saying the next line has an error. It is itself an error if there isn't one.
- `@ts-ignore`
- A comment that hides any error on the next line, and never warns when it is no longer needed.
- `TS2589`
- The error code for a type that is excessively deep or possibly infinite.
- Extended diagnostics
- A
tscoption (--extendedDiagnostics) that prints how much work and time the checker used.
Step by step
01Write a type test
Equal compares two types by checking that two generic functions are identical. Expect<T extends true> only accepts true.
A passing test produces no output. A failing one (Expect<Equal<ReturnType<() => string>, number>>) is a normal compile error: Type 'false' does not satisfy the constraint 'true'.
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
type Test = Expect<Equal<Exclude<"a" | "b", "a">, "b">>;02Know what Equal does not hide
Equal<{ a: 1 } & { b: 2 }, { a: 1; b: 2 }> is false: the same properties but a different type construction. Strict Equal is exact, which is good for tests and annoying when you didn't expect it.
Equal<any, string> is false too. A naive check would say true because any is assignable both ways. For simple cases, a helper function (expectType<number>(x)) that just assigns is a lighter test.
03Prove errors with @ts-expect-error
Put the directive above a line that must fail. When the error exists, you see nothing. When someone changes the type so the line is accepted, the directive is flagged as unused.
@ts-ignore would stay silent in both cases. That is why it is the weaker choice.
04Meet the depth limit
Path<Shop> builds every dotted path of an object: "owner.address.city". It is useful, and the typo error even suggests the fix.
A counter type that recurses 5000 times fails with TS2589. The fix is to not do that at the type level: real programs should compute big numbers at runtime.
05Decide when to stop
Ask three questions before shipping a clever type: Would a teammate understand the hover? Does it stop a bug that has really happened? Is there a simpler type plus a runtime check that does the same?
If two answers are no, delete it. A clear 3-line type that is slightly less precise is usually the better design.
Try it yourself
- 1
Fix the failing test
In the first example change
numberinTest4tostringand check that the error goes away. Then changeTest2so it should fail and read the message. - 2
Watch the directive turn on you
In the second example change
const ok: number = 7;toconst ok: number = "seven";. TheUnusederror disappears, because there is now an error to expect. Then do the same to line 7: nothing changes for@ts-ignoreeither way. - 3
Find the limit
In the third example try
Count<50>, thenCount<500>, thenCount<5000>. Where doesTS2589start? (Checked here:Count<999>works andCount<1000>fails, because tail-recursive conditional types are cut off at 1000 steps.) Then write the counter non-tail-recursively, with the recursive call wrapped:[...CountNT<N, [...A, 0]>]in the false branch. ReadingCountNT<N>["length"], 47 works and 48 already fails: a wrapped call is not optimised, so it hits a much lower limit.
Code & diagrams
Test4 is wrong on purpose and produces the only type-test error. Fix it to string and it disappears. Nothing in this file produces output at runtime except the last line: type tests cost nothing when the program runs.
Console
(nothing was printed)Expected console output
types are checked, nothing runsLine 1 is a real, reported error. Line 3 is silenced. The directive above line 5 is reported because there is nothing to expect. @ts-ignore on line 7 hides its error without comment.
Console
(nothing was printed)Expected console output
five six 7 eightPath is a good clever type: typos in a dotted path become errors with a suggestion. Count<3> is fine; Count<5000> hits the compiler's depth limit. The program itself still runs.
Console
(nothing was printed)Expected console output
owner.address.city owner.adress.cityBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
A type test that fails
In the first example, leave Test4 comparing ReturnType<() => string> with number.
Break #2
A directive with nothing to expect
Put // @ts-expect-error above a line that has no error, const ok: number = 7;.
Break #3
Recurse too deep
Ask for Count<5000>.
Myth vs fact
Myth
@ts-ignore and @ts-expect-error do the same thing.
Fact
Both hide an error, but @ts-expect-error fails when there is nothing to hide. @ts-ignore never does, so it silently hides new problems.
Myth
More precise types are always better.
Fact
Precision has a cost in readability, compile time and error text. A type should be as precise as the bugs it prevents, and no more.
Myth
Type tests need a testing library.
Fact
A two-line Equal and Expect is enough. Libraries add nicer messages.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The
Equaltrick relies on how the checker compares two generic function types: they are identical only if their conditional types are identical, which makesany, intersections and readonly differences visible. This is stricter than mutual assignability and is why{ a: 1 } & { b: 2 }differs from{ a: 1; b: 2 }. Newer libraries use the same idea. - ▸
Performance clues: very wide unions (hundreds of members) multiplied together, deeply nested mapped types over big interfaces, and recursive types that are not tail-recursive. Measure with
tsc --extendedDiagnosticsand--generateTrace(Topic 12.6). The native compiler in TypeScript 7 runs the same checks about ten times faster, so a type that takes 10 s in 6.0 may take about one second, but exponential growth stays exponential. - ▸
A good test of a library's types lives in the test suite, next to runtime tests:
expectTypeOf(fn).returns.toEqualTypeOf<string>()in Vitest (type tests are*.test-d.tsfiles, checked byvitest --typecheck, which runstsc --noEmit; they are never executed) or thetsdpackage. Run it in CI, so a breaking type change fails a build, not a customer.
Remember this
- 1
The problem this solves: A very clever type can be unreadable, slow the editor and produce huge error messages, so the team stops trusting or touching it. Types get "too clever" in four ways. Readability: only the author can say what it means. Compile time: deeply recursive or huge-union types make the editor lag, and a build slow. Error messages: when a clever type rejects a value, the message may be 30 lines of generated names. Maintenance: when it breaks after an upgrade, nobody can fix it. Ask: does this type prevent a real bug that has happened or will?
- 2
The checker has limits. When a type is evaluated too deeply it stops with
error TS2589: Type instantiation is excessively deep and possibly infinite.A recursive conditional type that calls itself in the tail position (as the very last thing, with no wrapper around the result) is optimised and can go deeper, but there is still a fixed cap. Unions also have caps: very large unions or cross-products of unions can make the checker give up. - 3
Test your types like code. A type test asserts what a type is. A well-known helper is
Equal<A, B>, which compares two types exactly, andExpect<T extends true>, which fails to compile unless its argument istrue.type Test = Expect<Equal<Pick<User, "id">, { id: number }>>is a unit test that runs inside the compiler. Libraries use this style (expectTypeOfin Vitest,tsd,dtslint). A plainEqualbuilt onA extends Bwould call{ a: 1 } & { b: 2 }and{ a: 1; b: 2 }equal, but the strict version above saysfalse, because it compares identity. - 4
Sometimes you want to prove that a line is an error.
// @ts-expect-errorplaced on the line above says "I expect an error on the next line". If there is an error, it is silenced. If the next line is fine, the directive itself becomes an error:TS2578: Unused '@ts-expect-error' directive. That is the point: it is a test that fails when the thing you expect to fail starts working. It is the right tool in type tests. - 5
// @ts-ignoresilences whatever is on the next line, whether or not there is an error. It never complains when it becomes unnecessary, so it hides new bugs and sits in the code for years. Prefer@ts-expect-error(add a short reason after it), and prefer fixing the type. Both should be rare in application code. - 6
Practical rules: name intermediate types so hovers read well; write one test per utility; keep generics to the layers where they pay (library boundaries, not every function); prefer a plain interface and a runtime check over a 20-line conditional; and measure with
tsc --extendedDiagnostics(Topic 12.6) if the editor feels slow. In TypeScript 7 the compiler is native and about ten times faster, which helps but does not fix an exponential type.
Explain it without notes
What is the difference between @ts-ignore and @ts-expect-error?
How would you test a utility type like DeepPartial?
What does TS2589 mean, and how can you avoid it?
How do you decide when a type is too clever?
Practice
Write a type test showing Omit<{ a: 1; b: 2 }, "a"> equals { b: 2 }, and a @ts-expect-error test showing Pick<{ a: 1 }, "z"> is rejected.
Explain in one sentence why the line // @ts-ignore above a correct line is dangerous, and rewrite it correctly.
Trade-offs
- ↔
Precise types catch more bugs, but cost readability, compile time and error clarity. Spend the precision where mistakes are expensive: public APIs and shared domain types.
- ↔
Type tests add a small file to maintain, but let you refactor complex utilities without fear. Not every helper needs one; every exported utility does.
Done when you can
I can write
EqualandExpectand use them as type tests.I can use
@ts-expect-errorin a test and explain why it beats@ts-ignore.I can recognise
TS2589and name two ways to avoid it.I can list four ways a type gets too clever.
I can decide whether a clever type earns its place.