Search code examples
typescripttypescript-typingstyping

Cannot find the proper typing for a certain kind of varargs function


Please have a look at the following little demo:

  • Function f should accept ONE pair of the form ['stringX', 'stringXstringX'] (-> this is working)
  • But function g should accept A VARIABLE NUMBER of pairs of this pattern. How do I have to type function g to make this happen?
function f<T extends string, U extends `${T}${T}`>(arg: [T, U]) {}
function g(...args: ???[]) {} // what's the right type here (-> ???)?

f(['a', 'aa']) // ok
f(['a', 'ab']) // error as expected

g(
  ['a', 'aa'], // should be okay
  ['b', 'bb'], // should be okay
  ['c', 'ab']  // should result in a compile error!!!
)

» TypeScript Playground

[Edit] Of course I can change function g to have n optional arguments combined with 2*n type parameters, but this is not the solution I am looking for.

[Edit] By the way: If function g would accept ONE ARRAY of those tuples instead of varargs and behave accordingly that would also be a useful solution, but I guess it's the same problem.

[Edit] Making g a single-argument function of the following form will work but is syntactically not very nice:

// expected error at the 'ab'
g(['a', 'aa'])(['b', 'bb'])(['c', 'ab'])

See DEMO


Solution

  • My approach here would look like this:

    function g<T extends string[]>(...args: { [I in keyof T]:
      [T[I], NoInfer<Dupe<T[I]>>]
    }) { }
    
    type Dupe<T> = T extends string ? `${T}${T}` : never;
    
    type NoInfer<T> = [T][T extends any ? 0 : never];
    

    The idea is that g() is generic in the type parameter T[], a tuple type consisting of just the string literal types from the first element of each pair of strings from args. So if you call g(["a", "aa"], ["b", "bb"], ["c", "cc"]), then T should be the tuple type ["a", "b", "c"].

    The type of args can then be represented in terms of T by a mapped tuple type. For each numeric index I from the T tuple, the corresponding element of args should have a type like [T[I], Dupe<T[I]>], where Dupe<T> concatenates strings to themselves in the desired way. So if T is ["z", "y"], then the type of args should be [["z", "zz"], ["y", "yy"]].

    The goal here is that the compiler should see a call like g(["x", "xx"], ["y", "oops"]) and infer T from the type of args to be ["x", "y"], and then check against the type of args and notice that while ["x", "xx"] is fine, ["y", "oops"] does not match the expected ["y", "yy"] and thus there is an error.

    That's the basic approach, but there are some issues we need to work around.


    First, there is an open issue at microsoft/TypeScript#27995 that causes the compiler to fail to recognize that T[I] will definitely be a string inside the mapped tuple type {[I in keyof T]: [T[I], Dupe<T[I]>]}. Even though a mapped tuple type creates another tuple type, the compiler thinks that I might be some other key of any[] like "map" or "length" and therefore T[I] could be a function type or number, etc.

    And so if we had defined Dupe<T> like

    type Dupe<T extends string> = `${T}${T}`;
    

    there would be a problem where Dupe<T[I]> would cause an error. In order to circumvent that problem, I have defined Dupe<T> so that it does not require that T is a string. Instead, it just uses a conditional type to say "if T is a string, then Dupe<T> will be `${T}${T}`":

    type Dupe<T> = T extends string ? `${T}${T}` : never;
    

    So that takes care of that problem.


    The next problem is that to infer T from a value of type {[I in keyof T]: [T[I], Dupe<T[I]>]}, the compiler is free to look both at the first element of each args pair (T[I]), as well as the second element (Dupe<T[I]>). Each mention of T[I] in that type is an inference site for T[I]. We want the compiler only to look at the first element of each pair when inferring T, and to just check the second element. Unfortunately, if you leave it like this, the compiler tries to use both and gets confused.

    That is, when inferring T from [["y", "zz"]], the compiler will match "y" agains T[I] and "zz" against Dupe<T[I]>, and it ends up inferring the union type ["y" | "z"] for T. That's quite clever, actually, since if T really were ["y" | "z"], then the type of args would be of type ["y" | "z", "yy" | "zz"], and the value ["y", "zz"] matches it. Nice! Except you want to reject [["y", "zz"]].

    So we need some way to tell the compiler not to use the second element of each pair to infer T. There is an open issue at microsoft/TypeScript#14829 which asks for some support for "non-inferential type parameter usage" of the form NoInfer<T>. A type like NoInfer<T> would eventually evaluate to T, but would block the compiler from trying to infer T from it. And so we'd write {[I in keyof T]: [T[I], NoInfer<Dupe<T[I]>>]}.

    There's no native or official NoInfer<T> type in TypeScript, but there are some implementations which work in some circumstances. I tend to use this version or something like it to take advantage of the compiler's tendency to defer evaluation of unresolved conditional types:

    type NoInfer<T> = [T][T extends any ? 0 : never];
    

    If you put some specific type in for T, this will evaluate to T (e.g., NoInfer<string> is [string][string extends any ? 0 : never] which is [string][0] which is string), but when T is an unspecified generic type, the compiler leaves it unevaluated and does not eagerly replace it with T. So NoInfer<T> is opaque to the compiler when T is some generic type parameter, and we can use it to block type inference.


    So there's the explanation (whew!). Let's make sure it works:

    g(
      ['a', 'aa'], // okay
      ['b', 'bb'], // okay
      ['c', 'dd']  // error!
      // -> ~~~~
      // Type '"dd"' is not assignable to type '"cc"'. (2322)
    )
    

    Looks good! The compiler is happy with ["a", "aa"] and ["b", "bb"] but balks at ["c", "dd"] with a nice error message about how it expected "cc" but got "dd" instead.


    This answers the question as asked. But do note that the typing for g() will only really be useful for callers of g() who do so with specific and literal strings passed in. As soon as someone calls g() with unresolved generic strings (say inside the body of some generic function), or as soon as you try to inspect T or args inside the body of the implementation of g(), you will find that the compiler cannot really understand the implications. In those cases you'll be better off widening to something like [string, string][] or the like and just being careful.

    Playground link to code