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>> // booleanBonus: 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 unboxHow 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>> // booleanBonus: 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.
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 layerEach branch is an infer pattern for one kind of box:
T extends (...args: any[]) => infer Inner matches any function and captures its return type. Unbox<() => number | string> must yield the whole union number | string, and return-type inference does exactly that.T extends Promise<infer Inner> captures the resolved type of a promise.T extends readonly (infer Inner)[] captures the element type of an array. Because tuples are subtypes of readonly arrays, the same branch handles [number] (giving number) and [[[number]]] (giving [[number]], one layer peeled). The readonly in the pattern is what lets it match both mutable and readonly variants.UnboxOne<number> is just number.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 fixpointFor 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)
// = booleanNote 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 numbersThe 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 togetherThe 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.
Unbox<number> β number: nothing to unwrap; the fixpoint check succeeds immediately.Unbox<(number | string)[]> β number | string: element-type inference preserves unions.Unbox<[number]> β number: tuples ride the readonly-array branch.Depth 4 and 5 give the same result): the identity branch absorbs the extra iterations.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.