Command Palette

Search for a command to run...

Hectal
PHASE 10Advanced ~22 min· topic 6 of 6

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 true only 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 tsc option (--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-tests.tswhole filets
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. 1

    Fix the failing test

    In the first example change number in Test4 to string and check that the error goes away. Then change Test2 so it should fail and read the message.

  2. 2

    Watch the directive turn on you

    In the second example change const ok: number = 7; to const ok: number = "seven";. The Unused error disappears, because there is now an error to expect. Then do the same to line 7: nothing changes for @ts-ignore either way.

  3. 3

    Find the limit

    In the third example try Count<50>, then Count<500>, then Count<5000>. Where does TS2589 start? (Checked here: Count<999> works and Count<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. Reading CountNT<N>["length"], 47 works and 48 already fails: a wrapped call is not optimised, so it hits a much lower limit.

Code & diagrams

Type tests with Equal and Expect

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 runs
@ts-expect-error versus @ts-ignore

Line 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 eight
Clever and useful, then too deep

Path 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.city

Break 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.

terminal
$ Types panel
── what you'll see ──
index.ts(9,21): error TS2344: Type 'false' does not satisfy the constraint 'true'.

Break #2

A directive with nothing to expect

Put // @ts-expect-error above a line that has no error, const ok: number = 7;.

terminal
$ Types panel
── what you'll see ──
index.ts(4,1): error TS2578: Unused '@ts-expect-error' directive.

Break #3

Recurse too deep

Ask for Count<5000>.

terminal
$ Types panel
── what you'll see ──
index.ts(19,13): error TS2589: Type instantiation is excessively deep and possibly infinite.

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 Equal trick relies on how the checker compares two generic function types: they are identical only if their conditional types are identical, which makes any, 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 --extendedDiagnostics and --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.ts files, checked by vitest --typecheck, which runs tsc --noEmit; they are never executed) or the tsd package. Run it in CI, so a breaking type change fails a build, not a customer.

Remember this

  1. 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. 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. 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, and Expect<T extends true>, which fails to compile unless its argument is true. type Test = Expect<Equal<Pick<User, "id">, { id: number }>> is a unit test that runs inside the compiler. Libraries use this style (expectTypeOf in Vitest, tsd, dtslint). A plain Equal built on A extends B would call { a: 1 } & { b: 2 } and { a: 1; b: 2 } equal, but the strict version above says false, because it compares identity.

  4. 4

    Sometimes you want to prove that a line is an error. // @ts-expect-error placed 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. 5

    // @ts-ignore silences 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. 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

01

What is the difference between @ts-ignore and @ts-expect-error?

02

How would you test a utility type like DeepPartial?

03

What does TS2589 mean, and how can you avoid it?

04

How do you decide when a type is too clever?

Practice

01

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.

02

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 Equal and Expect and use them as type tests.

  • I can use @ts-expect-error in a test and explain why it beats @ts-ignore.

  • I can recognise TS2589 and 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.