#734Extreme

Inclusive Range

Build the tuple of every integer from Lower to Higher, both included. The recursion depth limit is the real obstacle, and tail recursion is the way past it.

InclusiveRange<Lower, Higher> returns the tuple of every integer from Lower to Higher, with both ends included. When Lower is greater than Higher, the answer is the empty tuple.

type R0 = InclusiveRange<0, 10> // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
type R1 = InclusiveRange<5, 5> // [5]
type R2 = InclusiveRange<10, 5> // []

Counting is the easy part. The obstacle is that both boundaries range from 0 to 200, and a recursive type written the obvious way gives up long before it reaches 200 elements. The upstream statement puts the depth limit at around 45, and that is roughly right: the naive version below compiles at 45 and fails with "Type instantiation is excessively deep and possibly infinite" at 50. Getting to 200 means writing the recursion in the one shape the compiler is willing to unroll without stacking.

Challenge Instructions: Inclusive Range

Extreme

Recursion depth in type system is one of the limitations of TypeScript, the number is around 45.

We need to go deeper*. And we could go deeper.

In this challenge, you are given one lower boundary and one higher boundary, by which a range of natural numbers is inclusively sliced. You should develop a technique that enables you to do recursion deeper than the limitation, since both boundary vary from 0 to 200.

Note that when Lower > Higher, output an empty tuple.

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

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

Loading...

Detailed Explanation

The solution in full:

type Enumerate<
  N extends number,
  Acc extends number[] = [],
> = Acc['length'] extends N ? Acc : Enumerate<N, [...Acc, Acc['length']]>
 
type DropFirst<
  T extends unknown[],
  N extends number,
  Dropped extends unknown[] = [],
> = Dropped['length'] extends N
  ? T
  : T extends [unknown, ...infer Rest]
    ? DropFirst<Rest, N, [...Dropped, unknown]>
    : []
 
type InclusiveRange<Lower extends number, Higher extends number> = DropFirst<
  [...Enumerate<Higher>, Higher],
  Lower
>

Two recursive walks, each of them at most 201 steps: build [0, 1, ..., Higher] first, then throw away the first Lower entries. No arithmetic, no comparison of Lower against Higher, and no special case for a backwards range.

The shape that runs out of depth

Here is the version most people write first, and the reason it does not survive the test file:

type Naive<N extends number, Acc extends unknown[] = []> = Acc['length'] extends N
  ? []
  : [Acc['length'], ...Naive<N, [...Acc, unknown]>]

The recursive call sits inside a tuple literal, so the compiler cannot finish Naive<N, ...> and hand the answer straight back. It has to hold the half-built [Acc['length'], ...] in memory while the inner call runs, then splice the result in. Fifty of those nested frames and you hit the instantiation limit.

Since TypeScript 4.5 there is an escape: when the recursive call is the whole branch of the conditional type, with nothing wrapped around it, the compiler reuses the same frame instead of nesting. That raises the ceiling from around 50 to 1000. The price is that partial results can no longer be assembled on the way out of the recursion, so they have to travel forward, as an extra type parameter.

Counting up with an accumulator

Enumerate<N> is that pattern in its smallest form:

// Enumerate<3> is [0, 1, 2]
// Enumerate<0> is []

Acc starts as [] and gains one element per step. The element it gains is Acc['length'], which on a tuple is a literal number type, not the general number. So the accumulator's own length doubles as the counter and as the next value to append, and the base case Acc['length'] extends N stops it one short of N. Both branches of the conditional are either Acc or the bare recursive call, which is what keeps it tail-recursive.

Adding the upper bound back is a spread away: [...Enumerate<Higher>, Higher] is [0, 1, ..., Higher], which contains every number the answer could possibly need.

Dropping the head

DropFirst<T, N> removes the first N elements of a tuple. It walks two things at once: Dropped grows by one unknown per step and acts as the counter, and T loses its head through the pattern [unknown, ...infer Rest]. The infer Rest binds everything after the first element, so peeling one element off costs one pattern match.

// DropFirst<[0, 1, 2, 3], 2> is [2, 3]
// DropFirst<[0, 1], 5>       is []

That second line is where the backwards range is handled. When N is larger than the tuple, T empties out before the counter reaches N, the pattern [unknown, ...infer Rest] stops matching, and the final branch returns []. InclusiveRange<10, 5> builds [0, 1, 2, 3, 4, 5] and then asks for ten elements to be dropped from it, so it falls into exactly that branch. No comparison operator is needed to notice that the range is empty, because running off the end of a tuple already means the same thing.

Edge cases the tests cover

This challenge is originally from here.

Share this challenge

Related Challenges

Learn the Concepts

Become a TypeScript Pro

Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges