Command Palette

Search for a command to run...

Hectal
PHASE 17Advanced ~32 min· topic 3 of 5

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 true only 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.

pick.tswhole filets
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].

includes.tswhole filets
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).

Challenge 8: Chainable. The type grows with every calldiagram
Rendering diagram…

Try it yourself

  1. 1

    Predict, then fail it

    In the second example, change the Good1 test to expect "a" | "b" (leave out 3). Predict the error line, then check. Why is the error on the Expect, not on TupleToUnion?

  2. 2

    Add an edge case

    In the second example, add type Good7 = Expect<Equal<Includes<[1, 2, 3], 4>, false>>; and type Good8 = Expect<Equal<Includes<[any], string>, false>>;. Both pass. Now change Equal<H, U> extends true to H extends U and see which of them break: with any as the element, any extends string gives boolean (the compiler explores both branches), so Good8 fails with TS2344, while Good7 still passes. That is another reason Equal is used for exactness.

  3. 3

    Allow overwrites

    In the fourth example, change the key parameter type from K extends keyof R ? never : K to just K and run it. The error on the last line disappears: a repeated 'name' is now accepted, and the ^? hover on result shows the same long type.

Code & diagrams

Pick and Readonly, wrong attempts first

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)
TupleToUnion, Last and Includes

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)
Awaited and DeepReadonly

The one-layer Awaited leaves a Promise. The shallow readonly lets the nested assignment through. The recursive versions fix both.

Console

(nothing was printed)
Chainable options

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.

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

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 deep much sooner (in the Topic 9.5 test, 47 steps worked and 48 failed). Includes and MyAwaited above are tail-recursive. The Flatten in the practice answers is not, which is fine for short tuples but fails on long ones.

  • ▸

    Equal is 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 say false although the types are mutually assignable. That is why the Chainable test flattens with Flat<T> first.

  • ▸

    The hover for result in 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. 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> is true only when A and B are exactly the same type, and Expect<T extends true> is a type that only accepts true. 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. 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. 3

    The tools you will reuse. Mapped types ({ [K in keyof T]: ... }, Topic 8.4), keyof and indexed access (T[K], T[number], Topics 8.1 and 8.3), conditional types with infer (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. 4

    Pitfalls the challenges teach. A type parameter without a constraint (K instead of K 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 : false distributes over unions (Topic 9.2), which gives the wrong answer for boolean. 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. 5

    These challenges come from the community 'type-challenges' collection. They are puzzles, not production code: in real projects you use the library Pick, Readonly and Awaited. 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. 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 TS2344 are intentional, the failing attempts. A final Good... line that is silent is a correct answer.

Explain it without notes

01

Walk me through how you solve a type challenge you have not seen before.

02

Why does T[0] not work for TupleToUnion, and what does T[number] mean?

03

Why is Last not T[T['length']], and how does the rest-pattern solution work?

04

Why does the simple Includes give the wrong answer for boolean, and how does Equal fix it?

05

How do you implement Awaited and DeepReadonly? Where does recursion stop?

06

How would you type a chainable option(key, value) builder so get() returns the accumulated type?

Practice

01

Write First<T extends unknown[]> so First<[1, 2]> is 1 and First<[]> is never. Test both.

02

Write Concat<A extends unknown[], B extends unknown[]> so Concat<[1], [2, 3]> is [1, 2, 3].

03

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-error line) 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 a TS2344 failure.

  • I can explain why K extends keyof T is needed in Pick.

  • I can use T[number], rest patterns and infer to take apart a tuple.

  • I can say why extends is the wrong check for Includes and use Equal.

  • I can write recursive Awaited and DeepReadonly.

  • I can type a chainable builder whose type grows with each call.