Search code examples
typescript

How to return tuple of same generic type as input?


I'm writing a function that accepts a tuple and returns a tuple of the same type. The input tuple could be any of a few different lengths, and I want the function to express that the returned tuple is of the same length; so I'm using a generic to type the input and output. However, the body of the function needs to modify the first element of the tuple separately from the rest of the elements in the tuple, so I have to destructure and reassemble the tuple. Here is a minimal example of what I can't seem to get TypeScript to accept:

type B = [string, string] | [string, string, string];

function foo<T extends B>([hd, ...tl]: T): T {
    return [hd, ...tl]
}

This gives me the following error:

Type '[string, string] | [string, string, string]' is not assignable to type 'T'.
  '[string, string] | [string, string, string]' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'B'.
    Type '[string, string]' is not assignable to type 'T'.
      '[string, string]' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'B'.(2322)

I believe what's happening is that TypeScript infers an overly general type for the tl variable. The tl variable's type should reflect that it's the tail of type T, but instead when I hover over tl it shows that it's inferred as the more general type [string] | [string, string]. Because tl's inferred type "forgets" its relation to the T type parameter, TypeScript incorrectly thinks that it's possible for T and [hd, ...tl] to be different subtypes of B.

Here is another similar example of TypeScript inferring overly general types that "forget" their connection to the generic type parameter:

type B = [1, 2] | [3, 4];

function foo<T extends B>([x, y]: T): T {
    return [x, y]
}

Here it thinks that the return value [x, y] is of type [1 | 3, 2 | 4], even though it's impossible for it to equal, for example, [1, 4].

How can I get TypeScript to recognize that reassembling a destructured generic is of the same type as the generic?


Solution

  • Generally speaking the only way TypeScript can be sure that you return a value of a generic type T is if you return a value it already knows is of that type. You might have some invariant in your mind about a generic type, like "for all arraylike T, if I split T into chunks and then re-assemble the chunks into an array, I get T back again", but TypeScript doesn't understand such things. Higher order reasoning about generics needs to be explicitly programmed into the language for specific circumstances; and this only happens to handle common cases where the benefit of doing extra type checking is worth the cost. Furthermore, such invariants tend not to be true:

    type B = [string, string] | [string, string, string];
    
    function foo<T extends B>([hd, ...tl]: T): T {
      return [hd, ...tl] // error!
      // 'T' could be instantiated with a different subtype of constraint 'B'
    }
    
    const input = Object.assign(["a", "b"] satisfies B, { prop: "hello" });
    /* const input: [string, string] & {
      prop: string;
    } */
    const output = foo(input);
    /* const output: [string, string] & {
      prop: string;
    } */
    
    try {
      output.prop.toUpperCase(); // no compiler error
    } catch (e) {
      console.log(e); // but 💥 RUNTIME ERROR! output.prop is undefined
    }
    

    Here the exact problem the compiler was warning you about came to pass. When I called foo(), I passed in a value of type [string, string] & {prop: string}, which is what T is instantiated with. Then we return a value that TypeScript knows is assignable to B, but it doesn't know if it's assignable to T. And it's not. The type of output is claimed to be the same as input, and therefore should have a string-valued prop property. It doesn't, causing an error at runtime.

    Even if you don't think such a thing is likely, you did say you're planning to modify the first element of the array. Again, it's very hard to be sure that anything you do to modify a value of that type will be assignable to that same type afterward, if that type is generic. Generics really constrain things in a way specific types don't. Just because a function turns strings into strings and numbers into numbers and Dates into Dates does not mean that it turns generic T extends string | number | Date into T. T could be a string or numeric literal type. Or it could be a Date with extra properties. Modification of values and generics are inherently incompatible.

    The easiest thing to do is say "I don't think this is something I should worry about" and just use a type assertion:

    function foo<T extends B>([hd, ...tl]: T): T {
      return [hd, ...tl] as T; // this is fine
    }
    

    If it meets your needs, and your mental model of T-becomes-T holds up in practice because nobody bothers to probe the pathological cases, then great. But it's not really a failing of TypeScript here that you need to use type assertions.


    On the other hand if you want some compiler-verified type safety, you should abandon the idea of T-in-and-T-out, and instead impose some specific structure on the input and output types. Your function can still be generic, but it should only be generic in the parts where you shuttle values around without modifying them. For example:

    type RestMatching<T extends any[], T0> =
      T extends [infer F, ...infer R] ? T0 extends F ? R : never : never;
    
    function foo<H extends B[0], T extends RestMatching<B, H>>(
      [hd, ...tl]: [H, ...T]
    ): [H, ...T] {
      return [hd, ...tl]
    }
    

    This might be overkill, but let's look at it. First, forget the complicated constraints and just imagine we've written

    function foo<H, T extends any[]>(
      [hd, ...tl]: [H, ...T]
    ): [H, ...T] {
      return [hd, ...tl]
    }
    

    The function has two generic type parameters; H for the head, and T for the tail of the input array. We use variadic tuple types to represent both the input and the output. TypeScript is able to verify that [hd, ...tl] and [H, ...T] match each other. There's no compiler error.

    The constraints just force you to pass in only things assignable to B. H must be assignable to B[0], the indexed access type corresponding to the first element of B. Once you choose H, then T must be assignable to RestMatching<B, H>, meaning that it's the rest of the members of B whose first element is H. That distributive conditional type RestMatching<B, H> splits B into its union members, checks each one for beginning with something assignable to H, and for any of them that do begin with an H-compatible thing, we gather the tail of that union member.

    You can verify this works:

    type B = [string, string] | [number, string, boolean] | [boolean, string, number, number];
    
    foo(["x", "y"]); // okay
    foo([1, "a", true]); // okay
    foo([false, "z", 1, 2]); // okay
    
    foo(["z", "a", true]); // error
    foo([1, "z", 1, 2]); // error
    foo([false, "y"]); // error
    

    Note that the above is just one of many ways to make the hd and tl inputs depend on each other in the proper way. It's fairly general for arbitrary B. But if you have a more structured B (like, every first element is a string literal type like ["add", number, number] | ["negate", number] for example) then you can write it in less complicated ways (using a type like {add: [number, number]; negate: [number]} for example). I won't go into these here.

    The important part here is that we're never trying to assert or claim that a modified thing is of the same generic type as the original thing. Anything we modify is of a specific, not generic type.

    Playground link to code