Search code examples
typescriptvariadic-functions

Double expand nested variadic tuples in TypeScript


tl;dr: I want to strongly type the following.

const foo = [ 'a' ] as const;
const bar = [ 1 ] as const;
const baz = [ true ] as const;

const concatted = foo.concat(bar, baz);

type Concatted = typeof concatted; // expect ['a', 1, true]

I've figured out how to add definitions for 0..n arguments, but I want to do it for an arbitrary number, preferably with one or two definitions.


Suppose I have:

const strArray = [ 'a' ] as const;
const numArray = [ 1 ] as const;

const concatenated = strArray.concat(numArray);

We know that concatenated is exactly equal to ['a', 1]. I've figured out how to write a type definition for concat() that gives us this.

declare global {
    interface ReadonlyArray<T> {
        concat<
            A extends ReadonlyArray<T>, 
            I extends ReadonlyArray<unknown>
        >(this: A, items: C): [...A, ...I];
    }
}

type Concatenated = typeof concatenated; // => ['a', 1]

However, JavaScript's Array.concat() takes in an arbitrary number of arrays. So now, let's do

const strArray = [ 'a' ] as const;
const numArray = [ 1 ] as const;
const boolArray = [ true ] as const;

const concatenated = strArray.concat(numArray, boolArray); // => [ 'a', 1, true ]

Prior to TypeScript 4's variadic tuples, the solution was something like

declare global {
    interface ReadonlyArray<T> {
        concat<
            A extends ReadonlyArray<T>, 
            I extends ReadonlyArray<unknown>
        >(this: A, items: I): [...A, ...I];
        concat<
            A extends ReadonlyArray<T>,
            I1 extends ReadonlyArray<unknown>, 
            I2 extends ReadonlyArray<unknown>
        >(this: A, item1: I1, item2: I2): [...A, ...I1, ...I2];
        // ...additional concat() definitions through I_n...
    }
}

I was hoping that with TypeScript 4, I could do something simpler

declare global {
    interface ReadonlyArray<T> {
        concat<
            A extends ReadonlyArray<T>, 
            I extends ReadonlyArray<ReadonlyArray<unknown>>
        >(this: A, ...items: I): [...A, ...(...I)];
    }
}

This apparently doesn't work. I'm guessing there's some black magic using the

((val: ReadonlyArray<ReadonlyArray<unknown>>) => void) extends ((val: [infer U, ...infer R]) => void)
    ? [...U, ...<something something recurse with R>] 
    : never

pattern that I've never gotten the hang of, perhaps in tandem with magic using Extract<> as seen in this answer.

It would be very nice if types could be recursive without any magic. Then I could easily write:

type Concat<T extends ReadonlyArray<ReadonlyArray<unknown>>> = 
    T extends [ReadonlyArray<unknown>, ...infer U]
        ? [...T[0], ...Concat<U>]
        : [];

interface ReadonlyArray<T> {
    concat<
        A extends ReadonlyArray<T>, 
        I extends ReadonlyArray<ReadonlyArray<unknown>>
    >(this: A, ...items: I): [...A, ...Concat<I>];
}

TypeScript Playground

As an aside, I've never understood why recursive types--as long as they resolve within n depths--aren't supported. Infinite recursion, of course, would be bad/impossible, but I would think this syntax could be supported for tuples of size 100 or less pretty easily/efficiently.


Solution

  • Official support for recursive conditional types will be added in Typescript 4.1
    With it in place we'll be able to:

    type Concat<T> = 
        T extends [infer Head, ...infer Tail] 
          ? Head extends ReadonlyArray<unknown> 
            ? [...Concat<Head>, ...Concat<Tail>] : [Head, ...Concat<Tail>]
          : T
    

    The above definition allows to pass arrays and/or values to concatenate into a new array (just like regular concat)

    Playground


    The recursion limit is 50 nested type instantiations