Skip to content
TR

Phase 8 · Interview prep · Lesson 8.2

Interview

Type challenges, easy to hard

Twelve type-level puzzles in rising difficulty, from First and Length to UnionToIntersection and IsUnion, each with tests and a proven solution.

60 min

Some interviews hand you a type and a few test cases and say: make these pass. It is the type-level version of a whiteboard algorithm. The good news is that almost every puzzle is built from the same five tools: indexed access, mapped types, conditional types, infer and recursion.

These twelve challenges start easy and end with two that trip up experienced developers. None of them repeat the utility types you rebuilt in Build utility types yourself.

How to use this

  1. Read the statement and the tests. Try to name the tool you'll need before writing anything.
  2. Open the starter in the Playground (or your editor) and write your solution until every Expect line compiles.
  3. Give each one about ten minutes. If you're stuck, open the hints in your head first: can I match a pattern with infer? do I need to recurse?
  4. Then open the solution, even if yours passes. The explanation often shows a shorter or more correct version.

Every test uses these two helpers. Expect only accepts true, and Equal is a strict equality check that also tells any, readonly and intersections apart:

type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type ok = Expect<Equal<"a", "a">>;
// @ts-expect-error -- Type 'false' does not satisfy the constraint 'true'.
type notOk = Expect<Equal<{ a: 1 }, { readonly a: 1 }>>;

1. First (easy)

Return the first element type of a tuple, or never for an empty tuple.

type First<T extends readonly unknown[]> = any; // your code
 
type cases = [
  Expect<Equal<First<[3, 2, 1]>, 3>>,
  Expect<Equal<First<[() => 123, { a: string }]>, () => 123>>,
  Expect<Equal<First<[]>, never>>,
  Expect<Equal<First<[undefined]>, undefined>>,
];

▶ Try it in the TypeScript Playground

Show the solution
type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type First<T extends readonly unknown[]> =
  T extends readonly [infer F, ...unknown[]] ? F : never;
 
type cases = [
  Expect<Equal<First<[3, 2, 1]>, 3>>,
  Expect<Equal<First<[() => 123, { a: string }]>, () => 123>>,
  Expect<Equal<First<[]>, never>>,
  Expect<Equal<First<[undefined]>, undefined>>,
];

Match the tuple against the pattern one element, then the rest and capture the element with infer. The tempting T[0] fails the empty case: [][0] is undefined, not never.

2. Length of a tuple (easy)

Return the length of a tuple as a number literal. Passing something that isn't an array must be an error.

type Length<T> = any; // your code
 
const tesla = ["tesla", "model 3", "model X", "model Y"] as const;
const spaceX = ["FALCON 9", "FALCON HEAVY", "DRAGON", "STARSHIP", "HUMAN SPACEFLIGHT"] as const;
 
type cases = [
  Expect<Equal<Length<typeof tesla>, 4>>,
  Expect<Equal<Length<typeof spaceX>, 5>>,
  // @ts-expect-error
  Length<5>,
  // @ts-expect-error
  Length<"hello world">,
];
Show the solution
type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type Length<T extends readonly unknown[]> = T["length"];
 
const tesla = ["tesla", "model 3", "model X", "model Y"] as const;
const spaceX = ["FALCON 9", "FALCON HEAVY", "DRAGON", "STARSHIP", "HUMAN SPACEFLIGHT"] as const;
 
type cases = [
  Expect<Equal<Length<typeof tesla>, 4>>,
  Expect<Equal<Length<typeof spaceX>, 5>>,
  // @ts-expect-error -- Type 'number' does not satisfy the constraint 'readonly unknown[]'.
  Length<5>,
  // @ts-expect-error -- Type 'string' does not satisfy the constraint 'readonly unknown[]'.
  Length<"hello world">,
];

A tuple's length property is a literal type (4), while an array's is number. The readonly unknown[] constraint matters: as const produces readonly tuples, which are not assignable to a mutable unknown[].

3. Concat (easy)

Join two tuples into one.

type Concat<T, U> = any; // your code
 
const tuple = [1] as const;
 
type cases = [
  Expect<Equal<Concat<[], []>, []>>,
  Expect<Equal<Concat<[], [1]>, [1]>>,
  Expect<Equal<Concat<typeof tuple, typeof tuple>, [1, 1]>>,
  Expect<Equal<Concat<["1", 2, "3"], [false, boolean, "4"]>, ["1", 2, "3", false, boolean, "4"]>>,
];
Show the solution
type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type Concat<T extends readonly unknown[], U extends readonly unknown[]> = [...T, ...U];
 
const tuple = [1] as const;
 
type cases = [
  Expect<Equal<Concat<[], []>, []>>,
  Expect<Equal<Concat<[], [1]>, [1]>>,
  Expect<Equal<Concat<typeof tuple, typeof tuple>, [1, 1]>>,
  Expect<Equal<Concat<["1", 2, "3"], [false, boolean, "4"]>, ["1", 2, "3", false, boolean, "4"]>>,
];

Variadic tuple types let you spread tuples inside a tuple type, exactly like array spread at runtime. Spreading also drops readonly, which is why the as const case produces a plain [1, 1].

4. Tuple to object (easy)

Turn a tuple of keys into an object whose keys and values are the same literals.

type TupleToObject<T> = any; // your code
 
const tuple = ["tesla", "model 3", "model X"] as const;
 
type cases = [
  Expect<Equal<TupleToObject<typeof tuple>, { tesla: "tesla"; "model 3": "model 3"; "model X": "model X" }>>,
];
 
// @ts-expect-error
type error = TupleToObject<[[1, 2], {}]>;
Show the solution
type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type TupleToObject<T extends readonly PropertyKey[]> = { [K in T[number]]: K };
 
const tuple = ["tesla", "model 3", "model X"] as const;
 
type cases = [
  Expect<Equal<TupleToObject<typeof tuple>, { tesla: "tesla"; "model 3": "model 3"; "model X": "model X" }>>,
];
 
// @ts-expect-error -- Type '[1, 2]' is not assignable to type 'PropertyKey'.
type error = TupleToObject<[[1, 2], {}]>;

T[number] turns the tuple into a union of its elements, and a mapped type over that union builds the object. PropertyKey is the built-in alias for string | number | symbol, which is exactly what can be an object key.

5. Last (easy)

Return the last element type of a tuple.

type Last<T extends readonly unknown[]> = any; // your code
 
type cases = [
  Expect<Equal<Last<[]>, never>>,
  Expect<Equal<Last<[2]>, 2>>,
  Expect<Equal<Last<[3, 2, 1]>, 1>>,
  Expect<Equal<Last<[() => 123, { a: string }]>, { a: string }>>,
];
Show the solution
type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type Last<T extends readonly unknown[]> = T extends readonly [...unknown[], infer L] ? L : never;
 
type cases = [
  Expect<Equal<Last<[]>, never>>,
  Expect<Equal<Last<[2]>, 2>>,
  Expect<Equal<Last<[3, 2, 1]>, 1>>,
  Expect<Equal<Last<[() => 123, { a: string }]>, { a: string }>>,
];

Rest elements can appear before other elements in a tuple pattern, so [...unknown[], infer L] matches the last one. You can't write T[T["length"] - 1]: there's no arithmetic on number types.

6. Includes (medium)

Return true if the tuple contains exactly the type U. "Exactly" is the hard part: boolean does not contain false, and { a: "A" } is not { readonly a: "A" }.

type Includes<T extends readonly unknown[], U> = any; // your code
 
type cases = [
  Expect<Equal<Includes<["Kars", "Esidisi", "Wamuu"], "Kars">, true>>,
  Expect<Equal<Includes<[1, 2, 3], 4>, false>>,
  Expect<Equal<Includes<[boolean, 2, 3], false>, false>>,
  Expect<Equal<Includes<[{ a: "A" }], { readonly a: "A" }>, false>>,
  Expect<Equal<Includes<[1 | 2], 1>, false>>,
  Expect<Equal<Includes<[null], undefined>, false>>,
  Expect<Equal<Includes<[null], null>, true>>,
];

▶ Try it in the TypeScript Playground

Show the solution
type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type Includes<T extends readonly unknown[], U> =
  T extends readonly [infer F, ...infer Rest]
    ? Equal<F, U> extends true
      ? true
      : Includes<Rest, U>
    : false;
 
type cases = [
  Expect<Equal<Includes<["Kars", "Esidisi", "Wamuu"], "Kars">, true>>,
  Expect<Equal<Includes<[1, 2, 3], 4>, false>>,
  Expect<Equal<Includes<[boolean, 2, 3], false>, false>>,
  Expect<Equal<Includes<[{ a: "A" }], { readonly a: "A" }>, false>>,
  Expect<Equal<Includes<[1 | 2], 1>, false>>,
  Expect<Equal<Includes<[null], undefined>, false>>,
  Expect<Equal<Includes<[null], null>, true>>,
];

The obvious answer, U extends T[number] ? true : false, checks assignability, and false is assignable to boolean. The fix is to walk the tuple one element at a time and compare each with the strict Equal. Explaining why the one-liner fails is worth as much as the fix.

7. Readonly for chosen keys (medium)

MyReadonly2<T, K> makes only the keys K readonly and leaves the rest untouched. Without K, every key becomes readonly. Unknown keys must be an error.

type MyReadonly2<T, K> = any; // your code
 
interface Todo {
  title: string;
  description?: string;
  completed: boolean;
}
 
type cases = [
  Expect<Equal<MyReadonly2<Todo, "title" | "description">, { readonly title: string; readonly description?: string; completed: boolean }>>,
  Expect<Equal<MyReadonly2<Todo>, Readonly<Todo>>>,
];
 
// @ts-expect-error
type error = MyReadonly2<Todo, "title" | "invalid">;
Show the solution
type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type Flatten<T> = { [K in keyof T]: T[K] };
 
type MyReadonly2<T, K extends keyof T = keyof T> = Flatten<
  { readonly [P in K]: T[P] } & { [P in keyof T as P extends K ? never : P]: T[P] }
>;
 
interface Todo {
  title: string;
  description?: string;
  completed: boolean;
}
 
type cases = [
  Expect<Equal<MyReadonly2<Todo, "title" | "description">, { readonly title: string; readonly description?: string; completed: boolean }>>,
  Expect<Equal<MyReadonly2<Todo>, Readonly<Todo>>>,
];
 
// @ts-expect-error -- Type '"invalid"' is not assignable to type 'keyof Todo'.
type error = MyReadonly2<Todo, "title" | "invalid">;

Split the object in two: the chosen keys with readonly, the others (filtered with key remapping) as they were. Both halves are homomorphic mapped types ([P in K] with K extends keyof T, and [P in keyof T as ...]), so optional modifiers like description? survive. Flatten merges the intersection into one object, which strict Equal requires.

8. Trim (medium)

Remove whitespace (spaces, tabs, newlines) from both ends of a string literal type.

type Trim<S extends string> = any; // your code
 
type cases = [
  Expect<Equal<Trim<"str">, "str">>,
  Expect<Equal<Trim<" str">, "str">>,
  Expect<Equal<Trim<"     str     ">, "str">>,
  Expect<Equal<Trim<"   \n\t foo bar \t">, "foo bar">>,
  Expect<Equal<Trim<"">, "">>,
];
Show the solution
type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type Whitespace = " " | "\n" | "\t";
 
type Trim<S extends string> =
  S extends `${Whitespace}${infer Rest}`
    ? Trim<Rest>
    : S extends `${infer Rest}${Whitespace}`
      ? Trim<Rest>
      : S;
 
type cases = [
  Expect<Equal<Trim<"str">, "str">>,
  Expect<Equal<Trim<" str">, "str">>,
  Expect<Equal<Trim<"     str     ">, "str">>,
  Expect<Equal<Trim<"   \n\t foo bar \t">, "foo bar">>,
  Expect<Equal<Trim<"">, "">>,
];

Template literal patterns with infer parse strings: `${Whitespace}${infer Rest}` matches any string starting with one whitespace character. Peel one character per step and recurse until neither end matches.

9. ReplaceAll (medium)

Replace every occurrence of From with To. An empty From changes nothing, and replaced text must not be scanned again.

type ReplaceAll<S extends string, From extends string, To extends string> = any; // your code
 
type cases = [
  Expect<Equal<ReplaceAll<"foobar", "bar", "foo">, "foofoo">>,
  Expect<Equal<ReplaceAll<"t y p e s", " ", "">, "types">>,
  Expect<Equal<ReplaceAll<"foobarbar", "", "foo">, "foobarbar">>,
  Expect<Equal<ReplaceAll<"barfoo", "bar foo", "foo">, "barfoo">>,
  Expect<Equal<ReplaceAll<"foboorfoboar", "bo", "b">, "foborfobar">>,
];
Show the solution
type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type ReplaceAll<S extends string, From extends string, To extends string> =
  From extends ""
    ? S
    : S extends `${infer Head}${From}${infer Tail}`
      ? `${Head}${To}${ReplaceAll<Tail, From, To>}`
      : S;
 
type cases = [
  Expect<Equal<ReplaceAll<"foobar", "bar", "foo">, "foofoo">>,
  Expect<Equal<ReplaceAll<"t y p e s", " ", "">, "types">>,
  Expect<Equal<ReplaceAll<"foobarbar", "", "foo">, "foobarbar">>,
  Expect<Equal<ReplaceAll<"barfoo", "bar foo", "foo">, "barfoo">>,
  Expect<Equal<ReplaceAll<"foboorfoboar", "bo", "b">, "foborfobar">>,
];

`${infer Head}${From}${infer Tail}` finds the first occurrence (the leftmost infer matches as little as possible). Recursing only on Tail is what stops "bo" → "b" from re-matching the text you just produced. The empty-From guard avoids matching at every position forever.

10. DeepReadonly (medium)

Make every property readonly, at every depth, including arrays and tuples. Functions stay as they are.

type DeepReadonly<T> = any; // your code
 
type X = {
  a: () => 22;
  b: string;
  c: { d: boolean; e: { f: { g: true; h: "string" } } };
  l: ["hi", { m: ["hey"] }];
};
 
type Expected = {
  readonly a: () => 22;
  readonly b: string;
  readonly c: { readonly d: boolean; readonly e: { readonly f: { readonly g: true; readonly h: "string" } } };
  readonly l: readonly ["hi", { readonly m: readonly ["hey"] }];
};
 
type cases = [Expect<Equal<DeepReadonly<X>, Expected>>];
Show the solution
type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type DeepReadonly<T> = T extends (...args: never[]) => unknown
  ? T
  : { readonly [K in keyof T]: DeepReadonly<T[K]> };
 
type X = {
  a: () => 22;
  b: string;
  c: { d: boolean; e: { f: { g: true; h: "string" } } };
  l: ["hi", { m: ["hey"] }];
};
 
type Expected = {
  readonly a: () => 22;
  readonly b: string;
  readonly c: { readonly d: boolean; readonly e: { readonly f: { readonly g: true; readonly h: "string" } } };
  readonly l: readonly ["hi", { readonly m: readonly ["hey"] }];
};
 
type cases = [Expect<Equal<DeepReadonly<X>, Expected>>];

▶ Try it in the TypeScript Playground

Three facts make this short. A homomorphic mapped type ([K in keyof T] on a type parameter) returns primitives unchanged, so string stays string without a special case. Applied to arrays and tuples it produces readonly arrays and tuples. Functions are objects too, so they need the explicit check, or they'd be turned into an empty object type.

11. UnionToIntersection (hard)

Turn A | B | C into A & B & C.

type UnionToIntersection<U> = any; // your code
 
type cases = [
  Expect<Equal<UnionToIntersection<"foo" | 42 | true>, never>>,
  Expect<Equal<UnionToIntersection<{ a: 1 } | { b: 2 }>, { a: 1 } & { b: 2 }>>,
  Expect<Equal<UnionToIntersection<(() => "foo") | ((i: 42) => true)>, (() => "foo") & ((i: 42) => true)>>,
];
Show the solution
type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type UnionToIntersection<U> =
  (U extends unknown ? (arg: U) => void : never) extends (arg: infer I) => void
    ? I
    : never;
 
type cases = [
  Expect<Equal<UnionToIntersection<"foo" | 42 | true>, never>>,
  Expect<Equal<UnionToIntersection<{ a: 1 } | { b: 2 }>, { a: 1 } & { b: 2 }>>,
  Expect<Equal<UnionToIntersection<(() => "foo") | ((i: 42) => true)>, (() => "foo") & ((i: 42) => true)>>,
];

Two steps. First, distribute to get a union of functions: ((arg: A) => void) | ((arg: B) => void). Then infer the parameter type. Parameters are in a contravariant position, and when TypeScript infers one type variable from several contravariant candidates it takes their intersection. So the union comes out as A & B. This is the classic "explain variance" challenge.

12. IsUnion (hard)

Return true only if T is a union type.

type IsUnion<T> = any; // your code
 
type cases = [
  Expect<Equal<IsUnion<string>, false>>,
  Expect<Equal<IsUnion<string | number>, true>>,
  Expect<Equal<IsUnion<"a" | "b" | "c" | "d">, true>>,
  Expect<Equal<IsUnion<{ a: string } | { a: number }>, true>>,
  Expect<Equal<IsUnion<{ a: string | number }>, false>>,
  Expect<Equal<IsUnion<[string | number]>, false>>,
  Expect<Equal<IsUnion<string | never>, false>>,
  Expect<Equal<IsUnion<string | unknown>, false>>,
  Expect<Equal<IsUnion<string | "a">, false>>,
  Expect<Equal<IsUnion<never>, false>>,
  Expect<Equal<IsUnion<boolean>, true>>,
];
Show the solution
type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
 
type IsUnion<T, All = T> =
  [T] extends [never]
    ? false
    : T extends unknown
      ? [All] extends [T]
        ? false
        : true
      : never;
 
type cases = [
  Expect<Equal<IsUnion<string>, false>>,
  Expect<Equal<IsUnion<string | number>, true>>,
  Expect<Equal<IsUnion<"a" | "b" | "c" | "d">, true>>,
  Expect<Equal<IsUnion<{ a: string } | { a: number }>, true>>,
  Expect<Equal<IsUnion<{ a: string | number }>, false>>,
  Expect<Equal<IsUnion<[string | number]>, false>>,
  Expect<Equal<IsUnion<string | never>, false>>,
  Expect<Equal<IsUnion<string | unknown>, false>>,
  Expect<Equal<IsUnion<string | "a">, false>>,
  Expect<Equal<IsUnion<never>, false>>,
  Expect<Equal<IsUnion<boolean>, true>>,
];

Save the whole union in All before distributing. Inside T extends unknown, T is one member at a time. If the whole union is assignable to that single member, there was only one member: not a union. The non-distributive [T] extends [never] handles never (which would otherwise distribute to never). Notice the surprises: string | "a" collapses to string, string | unknown to unknown, and boolean really is the union true | false.

Quick check

Why does Includes<[boolean], false> return true with the one-liner U extends T[number] ? true : false?

Quick check

Which tool turns a union into an intersection in UnionToIntersection?

Recap

  • Tuple puzzles: T["length"], T[number], variadic [...T, ...U], and patterns like [infer F, ...infer R] or [...unknown[], infer L].
  • String puzzles: template literal patterns with infer, peeling one piece at a time, recursing on the rest.
  • Object puzzles: homomorphic mapped types keep ? and readonly; key remapping with as ... never filters keys.
  • extends in a conditional is assignability, not equality; use a strict Equal for exact checks.
  • Contravariant inference intersects candidates (UnionToIntersection); wrapping in [T] stops distribution (IsUnion).
  • Next: the classic gotchas and live coding exercises.