Implement Array.slice in the type system. Start and End are optional, negative indexes count from the end, and out-of-range values collapse to an empty tuple.
Slice<Arr, Start, End> reproduces Array.prototype.slice at the type level: the result is the subarray of Arr running from index Start up to but not including End.
type Arr = [1, 2, 3, 4, 5]
type Result = Slice<Arr, 2, 4> // expected to be [3, 4]The signature is the whole difficulty. Both index arguments are optional, both may be negative and count backwards from the end, and both may point outside the array, in which case JavaScript silently clamps rather than throwing. The type system has no arithmetic operators, so every one of those rules has to be expressed as tuple surgery.
Implement the JavaScript Array.slice function in the type system. Slice<Arr, Start, End> takes the three argument. The output should be a subarray of Arr from index Start to End. Indexes with negative numbers should be counted from reversely.
For example
type Arr = [1, 2, 3, 4, 5]
type Result = Slice<Arr, 2, 4> // expected to be [3, 4]View on GitHub: https://tsch.js.org/216
Change the following code to make the test cases pass (no type check errors).
The solution in full:
type Counter<N extends number, Acc extends unknown[] = []> = Acc['length'] extends N
? Acc
: Counter<N, [...Acc, unknown]>
type Prefix<
T extends unknown[],
N extends unknown[],
Acc extends unknown[] = [],
> = N extends [unknown, ...infer NRest]
? T extends [infer Head, ...infer TRest]
? Prefix<TRest, NRest, [...Acc, Head]>
: Acc
: Acc
type Drop<T extends unknown[], N extends unknown[]> = N extends [
unknown,
...infer NRest,
]
? T extends [unknown, ...infer TRest]
? Drop<TRest, NRest>
: []
: T
type ResolveIndex<Arr extends unknown[], I extends number> = `${I}` extends `-${infer M extends number}`
? Drop<Arr, Counter<M>>
: Prefix<Arr, Counter<I>>
type Slice<
Arr extends unknown[],
Start extends number = 0,
End extends number = Arr['length'],
> = Drop<Prefix<Arr, ResolveIndex<Arr, End>>, ResolveIndex<Arr, Start>>The idea underneath it: never represent an index as a number. Represent it as a tuple whose length is that index, and every clamp the specification asks for falls out of running off the end of a tuple.
Counter<N> grows an accumulator one unknown at a time until its length matches N. A recursive type cannot mutate anything, so the partial result travels as an extra type parameter with a default of [], and the base case reads it back out.
// Counter<3> is [unknown, unknown, unknown]
// Counter<0> is []That is the bridge from a number literal into tuple space. Everything after this works on lengths.
Prefix<T, N> and Drop<T, N> both take the count as a tuple rather than a number, so they can consume N and T in lockstep with one variadic pattern each. Prefix keeps the elements it walks past, Drop discards them.
// Prefix<[1, 2, 3, 4, 5], Counter<2>> is [1, 2]
// Drop<[1, 2, 3, 4, 5], Counter<2>> is [3, 4, 5]The interesting part is what happens when they disagree about who runs out first. If T empties while N still has entries, Prefix returns the accumulator it has collected so far and Drop returns []. Neither errors. That is exactly the clamping behavior of the JavaScript method, obtained for free instead of as a guard.
ResolveIndex turns an index argument into a tuple whose length is the absolute position it names. The negative test is a string one: `${I}` stringifies the number literal, and the pattern `-${infer M extends number}` matches only if it starts with a minus sign, capturing the magnitude back as a number in the same step.
For a non-negative I, the answer is min(I, length), which is the length of the first I elements of Arr. For a negative -M, the answer is max(0, length - M), which is the length of what remains after dropping M elements. Both are lengths of tuples we already know how to build.
// ResolveIndex<[1, 2, 3, 4, 5], 2>['length'] is 2
// ResolveIndex<[1, 2, 3, 4, 5], -1>['length'] is 4
// ResolveIndex<[1, 2, 3, 4, 5], 10>['length'] is 5With both indexes resolved, Slice cuts the tail first and the head second: Prefix<Arr, ResolveIndex<Arr, End>> keeps everything before End, then Drop removes everything before Start from that. Order matters, because both indexes are stated in coordinates of the original array. Slicing off the head first would shift End by Start positions and require an arithmetic correction the type system cannot express cheaply.
The defaults finish the signature: Start defaults to 0, and End defaults to Arr['length'], which is the array's own length read straight off the tuple.
Slice<Arr> and Slice<Arr, 0> return Arr unchanged. Prefix rebuilds the tuple element by element, so the result is structurally identical, not a widened array type.Slice<[]> returns []. The End default evaluates to 0 here, so nothing special is needed for the empty input.Slice<Arr, 0, -1> returns [1, 2, 3, 4]. -1 resolves to length 4, one short of the end.Slice<Arr, -3, -1> returns [3, 4]. Both ends are negative and resolve independently, so a backwards Start composes with a backwards End without extra cases.Slice<Arr, 10> and Slice<Arr, 10, 20> return []. Both indexes clamp to the array length, and dropping a full-length prefix leaves nothing.Slice<Arr, 1, 0> returns []. Here Start is past End, so Drop is handed a count longer than the tuple it is trimming and empties it.This challenge is originally from here.
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges