Topic 17.3
Live Type Challenges
In one line
Solve eight classic type challenges (Pick, Readonly, TupleToUnion, Last, Includes, Awaited, DeepReadonly, Chainable) the way you would in an interview: write the test first, try the naive answer, read the error, fix it.
Think of it like this
A crossword. You do not write the answers in order. You read the clue (the test), try a word, notice it does not fit the crossing letters (the error), and change it. A type challenge is a crossword where the compiler checks the crossing letters. The clue is a line like Expect<Equal<MyPick<Todo, 'title'>, { title: string }>>. The answer is a type that makes that line compile.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Type challenge
- A puzzle where you write a type so a test line compiles.
- Equal
- A helper type that is
trueonly when two types are exactly identical. - Expect
- A helper type that only accepts
true, so a wrong answer is a compile error. - Tuple
- An array with a fixed length and a known type at each position, like
[string, number]. - Indexed access
- Reading a part of a type:
T['title']is the type of the title property,T[number]is the type of every element. - Recursion (in types)
- A type that refers to itself, used to unwrap or walk through nested structures.
- Builder / chainable
- An object whose methods return a new object, so calls can be strung together.
- Naive attempt
- The first, obvious solution. It often fails in an instructive way.
Step by step
01Challenge 1: Pick. The test first
Goal: MyPick<Todo, 'title'> is { title: string }. Attempt 1 writes type PickV1<T, K> = { [P in K]: T[P] } and gets two errors: Type 'K' is not assignable to type 'string | number | symbol' and Type 'P' cannot be used to index type 'T'.
The error names the missing idea: K must be a key of T. Add the constraint K extends keyof T. Now the test passes, and a bad key (MyPick<Todo, 'owner'>) is rejected with Type '"owner"' does not satisfy the constraint 'keyof Todo'. A constraint is both the fix and the guard.
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };02Challenge 2: Readonly. A wrong answer that compiles
Attempt 1 copies the object: { [K in keyof T]: T[K] }. It compiles with no error at all, but the test line Expect<Equal<ReadonlyV1<Todo>, Readonly<Todo>>> fails, because the properties are not readonly.
Lesson: no error does not mean a right answer. Only the test tells you. Fix: add the modifier, { readonly [K in keyof T]: T[K] }.
03Challenge 3 to 5: TupleToUnion, Last, Includes
TupleToUnion<['a', 'b']> must be 'a' | 'b'. Attempt T[0] gives only 'a' (the test fails). Fix: T[number], which means 'the type of any element'.
Last<[1, 2, 3]> must be 3. Attempt T[T['length']] returns undefined: the length of a three-element tuple is the number 3, and index 3 does not exist. Fix: match the shape with a rest pattern, T extends [...unknown[], infer L] ? L : never. The empty tuple falls through to never, which a second test line confirms.
Includes<[boolean], true> must be false. Attempt U extends T[number] says true, because true is assignable to boolean. Fix: compare element by element with Equal<H, U> and recurse through the tail with T extends [infer H, ...infer R].
type Includes<T extends readonly unknown[], U> = T extends [infer H, ...infer R]
? Equal<H, U> extends true
? true
: Includes<R, U>
: false;04Challenge 6 and 7: Awaited and DeepReadonly. Recursion
MyAwaited<Promise<Promise<string>>> must be string. Attempt 1 unwraps once and the // ^? shows Promise<string>: you can see exactly one layer was removed. Fix: call yourself on the result, T extends PromiseLike<infer U> ? MyAwaited<U> : T.
DeepReadonly: the shallow mapped type compiles, and assigning to s1.theme.dark is silently allowed. The recursive version stops at functions (they are objects too, but you do not want to map over them) and wraps every nested object. Then s2.theme.dark = true is TS2540 and s2.tags.push('x') is Property 'push' does not exist on type 'readonly string[]'.
05Challenge 8: Chainable. The type grows with every call
config.option('name', 'Asha').option('age', 30).get() must be { name: string; age: number }. A fixed return type (ChainV1) cannot do that, because every call adds a key. The idea: make the type itself a parameter, Chainable<R>, and have option return Chainable<R & Record<K, V>>.
To reject a repeated key, give the key parameter the type K extends keyof R ? never : K: passing 'name' twice gives Argument of type '"name"' is not assignable to parameter of type 'never'. The Omit<R, K> & part replaces an old type if you allow overwriting. While repeats are rejected it never has anything to remove, so it matters only if you drop the never trick (see Try it).
Try it yourself
- 1
Predict, then fail it
In the second example, change the
Good1test to expect"a" | "b"(leave out3). Predict the error line, then check. Why is the error on theExpect, not onTupleToUnion? - 2
Add an edge case
In the second example, add
type Good7 = Expect<Equal<Includes<[1, 2, 3], 4>, false>>;andtype Good8 = Expect<Equal<Includes<[any], string>, false>>;. Both pass. Now changeEqual<H, U> extends truetoH extends Uand see which of them break: withanyas the element,any extends stringgivesboolean(the compiler explores both branches), soGood8fails withTS2344, whileGood7still passes. That is another reasonEqualis used for exactness. - 3
Allow overwrites
In the fourth example, change the
keyparameter type fromK extends keyof R ? never : Kto justKand run it. The error on the last line disappears: a repeated'name'is now accepted, and the^?hover onresultshows the same long type.
Code & diagrams
PickV1 has no constraint on K and fails. ReadonlyV1 compiles but the test line fails. The last line proves that MyPick rejects a key that is not there.
Console
(nothing was printed)Each naive attempt fails its test (index 0, index length, and the extends check on boolean). The fixed versions pass silently.
Console
(nothing was printed)The one-layer Awaited leaves a Promise. The shallow readonly lets the nested assignment through. The recursive versions fix both.
Console
(nothing was printed)Each option call adds a key to the type. The repeated name key on the last line is rejected with the never trick.
Console
(nothing was printed)Expected console output
{ name: 'Asha', age: 30, admin: true }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
Use extends instead of Equal in Includes
In the second example, change Equal<H, U> extends true to H extends U in the Includes type.
Myth vs fact
Myth
Type challenges are only trivia for interviews.
Fact
The skills (reading a type error, adding a constraint, recursion, infer) are the same ones you need when typing a generic API client or a form library. The puzzles are practice for that.
Myth
If the code compiles, my type is right.
Fact
A wrong type can compile cleanly (the shallow Readonly). Only a test line, or a use that must fail, proves it.
Myth
The more complex the type, the better the answer.
Fact
The best answers are short. If your solution is longer than the library version by a lot, look again for the simpler idea.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Tail-recursive conditional types, where the recursive call is the whole result of the branch, are turned into a loop by the checker (TypeScript 4.5) and allowed about 1000 iterations. Non-tail recursion, where the call sits inside other text or a tuple, hits
Type instantiation is excessively deepmuch sooner (in the Topic 9.5 test, 47 steps worked and 48 failed).IncludesandMyAwaitedabove are tail-recursive. TheFlattenin the practice answers is not, which is fine for short tuples but fails on long ones. - ▸
Equalis not an official feature. It relies on how the checker compares generic functions, and a rare edge case (intersections of object types, for example{ a: 1 } & { b: 2 }against{ a: 1; b: 2 }) can sayfalsealthough the types are mutually assignable. That is why the Chainable test flattens withFlat<T>first. - ▸
The hover for
resultin Chainable is long (Omit<Omit<...>>). That is the real type. Flattening with a mapped type gives a readable display, which matters for error messages in a library (Topic 16.6). - ▸
All eight challenges produce the same errors in TypeScript 6.0 and 7.0 (the live panel is cross-checked against the native compiler).
Remember this
- 1
The problem this solves: type-level puzzles are hard to judge by eye, and a wrong type can compile with no error at all. Writing a test line first turns the compiler into a judge that says right or wrong. The test comes first. Every challenge below begins with two helpers:
Equal<A, B>istrueonly whenAandBare exactly the same type, andExpect<T extends true>is a type that only acceptstrue. If your answer is wrong, the test line is an error,Type 'false' does not satisfy the constraint 'true'. A passing test is silent. That makes the compiler your judge. - 2
The method. (1) Say what the result must look like for two inputs. (2) Write the most obvious attempt. (3) Read the exact error or the
// ^?result: it names the missing idea. (4) Fix that one idea. (5) Add an edge case (an empty tuple, a union,boolean) and a case that must be rejected. Interviewers care much more about steps 3 to 5 than about knowing the answer in advance. - 3
The tools you will reuse. Mapped types (
{ [K in keyof T]: ... }, Topic 8.4),keyofand indexed access (T[K],T[number], Topics 8.1 and 8.3), conditional types withinfer(Topics 9.1 and 9.3), recursion (Topic 9.5), tuples (Topic 5.2) and template literal types (Topic 9.4). If you can name which of these a challenge needs before you start typing, you are most of the way there. - 4
Pitfalls the challenges teach. A type parameter without a constraint (
Kinstead ofK extends keyof T) cannot be used to index.T[number]reads all elements of a tuple,T[0]only the first.T extends U ? true : falsedistributes over unions (Topic 9.2), which gives the wrong answer forboolean. A shallow mapped type does not make nested objects readonly. And recursion is how you unwrap nested things such as a promise of a promise. - 5
These challenges come from the community 'type-challenges' collection. They are puzzles, not production code: in real projects you use the library
Pick,ReadonlyandAwaited. The value is the thinking, and that thinking transfers to the tricky generic helper you will one day write at work (Topic 16.4 goes deeper on type-level programming). - 6
Reading the live examples: each challenge shows the wrong attempt and the real error beside the fix. In the Types panel the lines with
error TS2344are intentional, the failing attempts. A finalGood...line that is silent is a correct answer.
Explain it without notes
Walk me through how you solve a type challenge you have not seen before.
Why does T[0] not work for TupleToUnion, and what does T[number] mean?
Why is Last not T[T['length']], and how does the rest-pattern solution work?
Why does the simple Includes give the wrong answer for boolean, and how does Equal fix it?
How do you implement Awaited and DeepReadonly? Where does recursion stop?
How would you type a chainable option(key, value) builder so get() returns the accumulated type?
Practice
Write First<T extends unknown[]> so First<[1, 2]> is 1 and First<[]> is never. Test both.
Write Concat<A extends unknown[], B extends unknown[]> so Concat<[1], [2, 3]> is [1, 2, 3].
Write Flatten<T> for one level: Flatten<[1, [2, 3], 4]> is [1, 2, 3, 4].
Trade-offs
- ↔
Clever type-level code is hard to read and slow to check. In production, prefer the library utility type or a simpler type that rejects the same bugs (Topic 10.6).
- ↔
A test next to a hard type (
Expect<Equal<...>>and one// @ts-expect-errorline) costs five lines and saves hours when someone changes the type later.
Done when you can
I can write
Expect<Equal<A, B>>tests and read aTS2344failure.I can explain why
K extends keyof Tis needed inPick.I can use
T[number], rest patterns andinferto take apart a tuple.I can say why
extendsis the wrong check forIncludesand useEqual.I can write recursive
AwaitedandDeepReadonly.I can type a chainable builder whose type grows with each call.