#32427β€’Hard

Unbox

Build Unbox<T>, which unwraps functions, promises, arrays and tuples, recursively or to a chosen depth. Fixpoint recursion meets a type-level counter.

Functions, promises, arrays and tuples all box a value type; Unbox<T> digs it out, however deeply they nest.

On the surface this is a handful of infer patterns. The bonuses turn it into a real design exercise: full recursion until nothing is left to unwrap, plus an optional depth argument that stops after exactly N layers, which means you'll implement a type-level counter.

Example:

Unbox<string> // string
Unbox<()=>number> // number
Unbox<boolean[]> // boolean
Unbox<Promise<boolean>> // boolean

Bonus: Can we make it recursive?

[object Object]

Double Bonus: Can we control the recursion?

Unbox<() => () => () => () => number, 3> // () => number
Unbox<Promise<Promise<number>>, 0> // number. Depth 0 (the default) means no limit: fully unbox

Challenge Instructions: Unbox

Hard

How can we build a type that "unboxes" arrays, functions, promises, and tuples?

Example:

Unbox<string> // string
Unbox<()=>number> // number
Unbox<boolean[]> // boolean
Unbox<Promise<boolean>> // boolean

Bonus: Can we make it recursive?

[object Object]

Double Bonus: Can we control the recursion?

[object Object]

View on GitHub: https://tsch.js.org/32427

Change the following code to make the test cases pass (no type check errors).

πŸ‘‹ Lifetime-License is leaving on August 10, 2026

Get it now for $29

One-time payment. Lifetime access to all pro challenges.

Loading...

Detailed Explanation

All four pieces up front:

type UnboxOne<T> = T extends (...args: any[]) => infer Inner
  ? Inner
  : T extends Promise<infer Inner>
    ? Inner
    : T extends readonly (infer Inner)[]
      ? Inner
      : T
 
type UnboxAll<T> =
  Equal<UnboxOne<T>, T> extends true ? T : UnboxAll<UnboxOne<T>>
 
type UnboxDepth<
  T,
  Depth extends number,
  Count extends unknown[] = [],
> = Count['length'] extends Depth
  ? T
  : UnboxDepth<UnboxOne<T>, Depth, [...Count, unknown]>
 
type Unbox<T, Depth extends number = 0> = Depth extends 0
  ? UnboxAll<T>
  : UnboxDepth<T, Depth>

The design principle: solve the smallest problem first (remove one layer), then build both bonuses on top of it. Equal here is the strict comparison type from the challenge's helpers.

UnboxOne: removing a single layer

Each branch is an infer pattern for one kind of box:

That last identity branch is not an afterthought: it's what lets the depth-counting version below idle safely when asked to unbox more layers than exist.

UnboxAll: recurse to a fixpoint

For the first bonus we keep unboxing until nothing changes:

type UnboxAll<T> =
  Equal<UnboxOne<T>, T> extends true ? T : UnboxAll<UnboxOne<T>>

Read it as: if removing a layer does nothing, you've hit the core, so stop. Otherwise remove the layer and repeat. This is a fixpoint recursion, and it saves you from re-listing all three box checks with recursive calls inside. Trace it on the nastiest test:

// UnboxAll<() => Promise<() => Array<Promise<boolean>>>>
// β†’ UnboxAll<Promise<() => Array<Promise<boolean>>>>
// β†’ UnboxAll<() => Array<Promise<boolean>>>
// β†’ UnboxAll<Array<Promise<boolean>>>
// β†’ UnboxAll<Promise<boolean>>
// β†’ UnboxAll<boolean>   (UnboxOne<boolean> is boolean β†’ stop)
// = boolean

Note the comparison uses Equal, not extends: we need "is the type literally unchanged?", and extends would give false positives on types that are mutually assignable without being identical. (Equal<X, Y>, defined as (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false, wraps both types in identical generic function signatures, which the compiler only treats as assignable when X and Y are exactly the same type.)

UnboxDepth: counting without numbers

The type system can't compute Depth - 1, so the second bonus uses the standard counter idiom: grow a tuple by one element per iteration and compare its 'length' to the target.

// UnboxDepth<Promise<Promise<Promise<number>>>, 2>
// Count = []        length 0 β‰  2 β†’ unbox once
// Count = [unknown] length 1 β‰  2 β†’ unbox once
// Count = [unknown, unknown] length 2 = 2 β†’ return Promise<number>

What if Depth exceeds the number of layers, as in Unbox<number[][][][], 5>? After four steps we're at number, and the fifth step calls UnboxOne<number>, whose identity branch returns number unchanged. The counter still terminates at 5 and the answer is the fully unboxed type. No overflow, no special case.

Unbox: gluing it together

The public type dispatches on the depth argument: Depth extends 0 ? UnboxAll<T> : UnboxDepth<T, Depth>. The default Depth = 0 means "no limit", which makes the one-argument form (Unbox<Promise<boolean>>) and the explicit Unbox<X, 0> tests behave identically. Both fully unbox.

Edge cases the tests cover

Splitting a hard type into a "do it once" core plus wrappers that repeat it is a decomposition you can reuse on nearly every recursive type challenge.

This challenge is originally from here.

Share this challenge

Learn the Concepts