Search code examples
typescripttypescript3.0

concatenate tuple with rest element


TypeScript 3.0 added rest elements to tuple types. However, calling concat() on such a tuple seems to lose the type:

type MyTupleType = [number, ...string[]];
let t1: MyTupleType = [42, 'foo'];
let t2 = t1.concat('bar');  // (string | number)[]

I can reassert the type using as MyTupleType, but I'm hoping for something simpler.


Solution

  • There's nothing simpler built into TypeScript. The standard library typings for Array.prototype.concat() do not care if the array in question is a tuple. The general problem of manipulating tuple types via array operations isn't easily solvable in TypeScript as of v3.2. There are some missing type operations that you'd need to do it properly.

    In the particular case of a "rest tuple" like you've got, the type signature for concat() is fairly straightforward: the output type is the same as the type of the current object (because adding zero or more of the "tail" elements doesn't change the tail). So it is possible to support the narrow use case you've mentioned here.

    You could do it by merging in your own declaration to add an overload for concat() that only is invoked if the array is a rest tuple and you're adding an element from the tail. Possibly like this:

    declare global {
      type IsRestTuple<T, Y=unknown, N=never> =
        T extends Array<any> ? number extends T['length'] ?
        T[number][] extends T ? N : Y : N : N
    
      interface Array<T> {
        concat<Tuple extends Array<any>>(
          this: Tuple & IsRestTuple<Tuple>,
          ...items: Array<Tuple[99999999] | Array<Tuple[99999999]>>
        ): Tuple;
      }
    }
    

    The IsRestTuple<T> type function returns unknown if T is a rest-type tuple, and never otherwise. It uses a bunch of conditional types to determine this.

    Then, the added generic overload uses a this parameter to constrain the type of this to something that passes the IsRestTuple test (the intersection Tuple & IsRestTuple<Tuple> infers Tuple as this. If IsRestTuple<Tuple> is unknown, then the intersection becomes Tuple and the type parameter inference succeeds. If it is never, then the intersection becomes never, and the type parameter inference fails.) Then it only accepts parameters of the tail of the rest type (I assume that the 99999999th element of the tuple will be part of the tail). If this overload is chosen, the output will be what you expect. Let's try it on a few examples:

    type MyTupleType = [number, ...string[]];
    let t1: MyTupleType = [42, 'foo'];
    let t2 = t1.concat('bar'); // [number, ...string[]], success
    let t3 = t1.concat(100); // Array<string | number>, 100 doesn't match string
    let t4 = t1.concat(false); // compile error, false doesn't match string or number
    let t5: [number] = [1];
    let t6 = t5.concat(1); // number[], would be [number, number] but use case unsupported
    

    So t2 is what you wanted it to be, and the rest of the cases are unaffected. This is the closest I can come to answering your question as asked. Unless you find that this particular use case is something you need to support a lot, it doesn't seem to be worth it: it's complicated and ugly and only works for rest tuples and concat(). The type assertion you're using looks quite attractive by comparison.

    Anyway, hope that helps; good luck!