Mert Özen Further With Every Line
Frontend TypeScript Advanced — #10

Type-Level Programming in TypeScript: Solving the Easy type-challenges

Mert Özen Nov 3, 2026 8 min 1 views
Type-Level Programming in TypeScript: Solving the Easy type-challenges

We solve ten easy type-challenges (MyPick, MyReadonly, TupleToObject, First, Length, MyExclude, MyAwaited, If, Concat, Includes), each checked with type-level tests built on Equal and Expect.

Version note: The code in this post targets TypeScript 7.0 and Node 26.

Predict First

A one-line type and three uses of it:

type Length<T extends readonly unknown[]> = T["length"];

type A = Length<["a", "b", "c"]>;
type B = Length<string[]>;
type C = Length<"abc">;

Which type does TypeScript infer for A and B? Does the C line compile? After all, "abc" has a length too. The answer is in one of this post's puzzles.

type-challenges and Testing at the Type Level

type-challenges is a community collection of type puzzles on GitHub. There are hundreds of questions ordered from easy to extremely hard; most of the 13 easy ones ask you to rewrite TypeScript's built-in utility types yourself. In this post we'll solve ten of them. Each one is a small application of the mapped types, conditional types, infer and recursive types we saw in earlier posts of this series.

The puzzles don't produce a running program; they produce types that either compile or don't. So how do we test that a type is correct? type-challenges uses two small helpers for that:

type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type MyExclude<T, U> = T extends U ? never : T;

type Wrong = Expect<Equal<MyExclude<"a" | "b" | "c", "a">, "b">>; // Error

Expect only accepts true. Equal produces true if the two types are exactly the same and false otherwise. If your expectation is wrong, the compiler says:

error TS2344: Type 'false' does not satisfy the constraint 'true'.

So our tests are compile errors. If tsc --noEmit stays quiet, all tests have passed. The odd-looking definition of Equal compares two generic function signatures, which triggers the compiler's internal "is this the same type" check. You'll see in the Common Pitfalls section why we don't use a simpler definition. We'll also use // @ts-expect-error: it says we expect an error on the next line, and if no error appears, the compiler reports that. That's how "this input must be rejected" tests are written.

Assume these two helpers sit at the top of every other block in this post; that's how we checked each block with tsc.

MyPick and MyReadonly: A Mapped Type Warm-Up

type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
type MyReadonly<T> = { readonly [P in keyof T]: T[P] };

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type PickCases = [
  Expect<Equal<MyPick<Todo, "title">, { title: string }>>,
  Expect<Equal<MyPick<Todo, "title" | "completed">, { title: string; completed: boolean }>>,
  // @ts-expect-error
  MyPick<Todo, "title" | "invalid">,
];

type ReadonlyCases = [Expect<Equal<MyReadonly<Todo>, Readonly<Todo>>>];

Both follow exactly the pattern from the mapped types post. The key point in MyPick is the constraint: without K extends keyof T you can't write T[P], because TypeScript can't be sure that P is a key of T. The same constraint also rejects a key that doesn't exist, like "invalid"; that's why the @ts-expect-error line stays quiet. In MyReadonly we add the readonly modifier to every property.

TupleToObject: From Tuple to Object

type TupleToObject<T extends readonly PropertyKey[]> = { [V in T[number]]: V };

const sizes = ["small", "medium", "large"] as const;

type TupleCases = [
  Expect<Equal<TupleToObject<typeof sizes>, { small: "small"; medium: "medium"; large: "large" }>>,
  Expect<Equal<TupleToObject<readonly [1, 2]>, { 1: 1; 2: 2 }>>,
  // @ts-expect-error
  TupleToObject<[[1, 2], {}]>,
];

Two familiar pieces come together here. T[number] uses indexed access to turn all of the tuple's elements into a union: "small" | "medium" | "large". The mapped type then makes each member of that union both a key and a value. The readonly in the constraint matters: as const produces a readonly tuple, and a readonly tuple doesn't fit a mutable array constraint. PropertyKey is the built-in name for string | number | symbol; thanks to it, we reject a tuple containing arrays or objects, since those can't be keys.

First and Length: Playing with Tuples

type First<T extends readonly unknown[]> = T extends readonly [infer F, ...unknown[]] ? F : never;
type Length<T extends readonly unknown[]> = T["length"];

type FirstCases = [
  Expect<Equal<First<[3, 2, 1]>, 3>>,
  Expect<Equal<First<[]>, never>>,
  Expect<Equal<First<[undefined]>, undefined>>,
  Expect<Equal<First<[() => 123, { a: string }]>, () => 123>>,
  // @ts-expect-error
  First<"notArray">,
];

type LengthCases = [
  Expect<Equal<Length<["a", "b", "c"]>, 3>>,
  Expect<Equal<Length<[]>, 0>>,
  Expect<Equal<Length<string[]>, number>>,
  // @ts-expect-error
  Length<5>,
];

The first idea for First is T[0]. It gives the right result most of the time, but on an empty tuple it produces undefined instead of never, and First<[]> looks the same as First<[undefined]>. If we describe the pattern "a tuple with at least one element" with infer, the empty tuple doesn't match and falls through to never.

Length is a single line, and here's the answer to the opening question. On tuples, length is a literal type: A is 3. The length of an ordinary array isn't known at compile time, so B is number. C doesn't compile: "abc" has a length property, but a string isn't an array and can't pass the constraint. tsc's message is "Type 'string' does not satisfy the constraint 'readonly unknown[]'." (TS2344).

MyExclude and If: Putting Distribution to Work

type MyExclude<T, U> = T extends U ? never : T;
type If<C extends boolean, T, F> = C extends true ? T : F;

type ExcludeCases = [
  Expect<Equal<MyExclude<"a" | "b" | "c", "a">, "b" | "c">>,
  Expect<Equal<MyExclude<string | number | (() => void), Function>, string | number>>,
];

type IfCases = [
  Expect<Equal<If<true, "a", "b">, "a">>,
  Expect<Equal<If<false, "a", 2>, 2>>,
  Expect<Equal<If<boolean, "a", 2>, "a" | 2>>,
  // @ts-expect-error
  If<null, "a", "b">,
];

MyExclude is the textbook example of the distributive behaviour from the conditional types post. Because T is a naked type parameter, each member of the union is checked separately: "a" turns into never, the others stay, and the results are joined back together. never drops out of a union, leaving "b" | "c".

In If, the same mechanism springs a surprise. boolean is really the union true | false; once it's distributed, both branches run and the result is "a" | 2. type-challenges lists that as the expected result on purpose. If you only want the first branch for a definite true, wrap both sides in square brackets as we saw in the conditional types post: [C] extends [true].

MyAwaited: Recursive infer

type MyAwaited<T extends PromiseLike<any>> =
  T extends PromiseLike<infer V> ? (V extends PromiseLike<any> ? MyAwaited<V> : V) : never;

type Thenable = { then: (onfulfilled: (arg: number) => any) => any };

type AwaitedCases = [
  Expect<Equal<MyAwaited<Promise<string>>, string>>,
  Expect<Equal<MyAwaited<Promise<Promise<number | boolean>>>, number | boolean>>,
  Expect<Equal<MyAwaited<Thenable>, number>>,
  // @ts-expect-error
  MyAwaited<number>,
];

This is the most "intermediate" question in the easy set. There are three ideas. First, PromiseLike instead of Promise: in JavaScript, await works with any object that has a then method, and the Thenable in the test list measures exactly that. Second, capturing the value inside the promise with infer V. Third, recursion: if the captured value is itself a promise, we call MyAwaited again until we reach a value that isn't a promise. The any here is a deliberate choice; it's a short way of saying "I don't care what value it carries, as long as it's thenable".

Concat and Includes: Spread and Exact Equality

type Concat<T extends readonly unknown[], U extends readonly unknown[]> = [...T, ...U];

type Includes<T extends readonly unknown[], U> =
  T extends readonly [infer F, ...infer R]
    ? Equal<F, U> extends true
      ? true
      : Includes<R, U>
    : false;

const one = [1] as const;

type ConcatCases = [
  Expect<Equal<Concat<[], []>, []>>,
  Expect<Equal<Concat<typeof one, typeof one>, [1, 1]>>,
  Expect<Equal<Concat<["1", 2], [false, boolean]>, ["1", 2, false, boolean]>>,
  // @ts-expect-error
  Concat<null, undefined>,
];

type IncludesCases = [
  Expect<Equal<Includes<["Ada", "Alan", "Grace"], "Alan">, true>>,
  Expect<Equal<Includes<["Ada", "Alan", "Grace"], "Linus">, false>>,
  Expect<Equal<Includes<[boolean, 2, 3], false>, false>>,
  Expect<Equal<Includes<[{ a: "A" }], { readonly a: "A" }>, false>>,
  Expect<Equal<Includes<[null], undefined>, false>>,
  Expect<Equal<Includes<[1 | 2], 1>, false>>,
];

Concat is the type-level counterpart of JavaScript's spread, and it's a single line. Includes is the most instructive question on the list. The first solution that comes to mind, U extends T[number] ? true : false, fails the [boolean, 2, 3] test: false is assignable to boolean, but the question is "is exactly false in the list?", and the answer is no. Assignability and equality are different things. So we walk the tuple recursively, element by element, and compare each element with Equal. The tests with a readonly object, with null/undefined and with a union measure the same difference.

Common Pitfalls

The first is writing Equal with a simple definition. [X] extends [Y] ? ([Y] extends [X] ? true : false) : false looks reasonable but breaks on any: any is assignable to everything, and everything to any. With that definition, NaiveEqual<any, string> comes out true, while the type-challenges definition gives false.

The second is forgetting readonly in constraints. Tuples produced with as const are readonly. If you write Includes with T extends [infer F, ...infer R], a readonly tuple never matches that pattern and the result is always false. You get no error, just a wrong answer, and that's the most dangerous kind.

The third is what distribution does with never. If you pass never to a naked type parameter, the conditional type never runs and the result is never; If<never, "a", "b"> gives neither "a" nor "b", but never.

The fourth is writing solutions without tests. Type-level bugs are silent; a solution that produces the wrong type usually compiles without any warning. Write the Expect lines first, then the solution.

A Small Experiment

★☆☆ Predict the type: MyExclude<"a" | "b", string>. Then confirm your guess with an Expect<Equal<…>> line.

★★☆ Solve two of the remaining easy questions: Push<[1, 2], "3"> should give [1, 2, "3"], and Unshift<[1, 2], 0> should give [0, 1, 2]. Write the tests first, then the solutions.

★★★ The last easy question: write MyParameters<T> without using the built-in Parameters. MyParameters<(a: string, b: number) => void> should be [a: string, b: number]. Hint: use infer in the parameter list of a function signature.

These puzzles build muscle for the type system, but in real projects you'll use that muscle most when typing existing JavaScript code step by step. That's exactly what we'll talk about in the next post: migrating a JavaScript project to TypeScript gradually.