3

Playground link

type X<T extends string> = `do something here with ${T}`

const foo = <const A extends string, B extends X<A>>(tuple: [a: A, b: B]) => tuple
foo(['a', `do something here with a`])

// How do I write foo2 such that it accepts an array (variable tuple...) where each member has type inference for its second element based on its first?
foo2([
    ['a', `do something here with a`],
    ['b', `do something here with b`],
    ['c', `do something here with c`],
    // ...
])

An array of tuples where the second element type depends on the first element type. Each tuple's type within the array should not affect types of other tuples in the array.

Jason Kuhrt
  • 736
  • 1
  • 4
  • 12
  • Does [this approach](https://tsplay.dev/WvkKYw) meet your needs? If so I could write up an answer explaining; if not, what am I missing? – jcalz Apr 01 '23 at 22:52
  • I found one counter example that [does not work](https://www.typescriptlang.org/play?#code/C4TwDgpgBAGgPAFShAHsCA7AJgZyj4AJwEsMBzAPigF4oADLAe30YFsJgALUsqTiQtADuxLlAAkAbwQBfOgCh5WCAGMANgENBUAGYBXDCuDFGGXY0YAmOCtMEoSVOmx5BGphjUh8RHgG0AXQoACmA9MDUIHAAuKDcPLyg-ADpUySSASShSKABrCBBGHQcA6PkoCqSNWIQ-DICAGigAI1j4WvqKAPkZAIBKWIA3RmIsAG5FHQtLYL9KqHLKvwByAC9lpoAiJhZ2Lh4+AWFRTigUTYCoAHorqDgAWnv8TkY9NSwW6AFCRkImlCgAEJqLRVvN5P0gA). Can you fix that? – Jason Kuhrt Apr 02 '23 at 00:59
  • Ah, maybe [this version](https://tsplay.dev/W4bPON) or [this version](https://tsplay.dev/Nav22m) then? – jcalz Apr 02 '23 at 01:04
  • The former version works! Impressive! A lot in here for me to learn. What is the advantage of the latter version? I see its syntax difference, but to what effect? – Jason Kuhrt Apr 02 '23 at 01:50
  • They are both ways of achieving *noninferential type parameter usage* as requested in [ms/TS#14829](https://github.com/microsoft/TypeScript/issues/14829). Not sure if one is more "advantageous" than another. Anyway I'll write up an answer when I get a chance. – jcalz Apr 02 '23 at 02:00

1 Answers1

3

You should make foo2() generic in T, the tuple type of the first ("a") elements of the inputs. So if you call

foo2([
    ['a', `do something here with a`],
    ['b', `do something here with b`],
    ['c', `do something here with c`],
]);

then T should be the tuple type ["a", "b", "c"]. Here's the first attempt at a solution:

declare function foo2<T extends readonly string[]>(
    tuples: readonly [...{ [I in keyof T]:
        [a: T[I], b: X<T[I]>]
    }]): void;

The tuples input is essentially of the mapped tuple type {[I in keyof T]: [a: T[I], b: X<T[I]>], meaning that for each element of T at index I, the value T[I] will be transformed into [a: T[I], b: X<T[I]>]. I've got that type wrapped in a variadic tuple type readonly [...⋯] in order to give the compiler a hint that we want T to be inferred as a tuple and not an unordered array type.

So if you pass in tuples as [["x", X<"x">], ["y", X<"y">]], then T should be inferred as ["x", "y"].

This mostly works:

foo2([
    ['a', `do something here with a`], // okay
    ['b', `do something here with b`], // okay
    ['c', `do something here with c`], // okay
    // ...
    ['x', "do something here with w"], // no error?!
    ['y', "do nothing"], // error
    ['z', "do something here with z"] // okay
])
// foo2<["a", "b", "c", "x" | "w", "y", "z"]>(⋯)

Except that for ['x', "do something here with w"], the union type "x" | "w" was inferred for the corresponding element of T. This is reasonable behavior, but not what you want. Indeed, you want T[I] to be inferred only from the first ("a") elements of the inputs, not from the second ("b") elements. That means you want the second elements to use T[I] only for checking, not for inferring.

There's a longstanding open issue at microsoft/TypeScript#14829 to support non-inferential type parameter usages. The idea is that there'd be a NoInfer<T> utility type so that one could write

declare function foo2<T extends readonly string[]>(
  tuples: readonly [...{ [I in keyof T]:
    [a: T[I], b: X<NoInfer<T[I]>>]
}]): void;

and the compiler would understand that it should not use that b element of the tuple to infer T[I]. There is currently (as of TS5.0) no direct support for this, but there are various techniques available which work. One is mentioned here, where you define NoInfer<T> as a conditional type in order to defer its evaluation:

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

With that definition of NoInfer<T>, you get the behavior you want:

foo2([
    ['a', `do something here with a`], // okay
    ['b', `do something here with b`], // okay
    ['c', `do something here with c`], // okay
    // ...
    ['x', "do something here with w"], // error!
    ['y', "do nothing"], // error! 
    ['z', "do something here with z"] // okay
])

Another approach is described here, where you add an additional type parameter constrained to the first one because constraints don't act as inference sites (see microsoft/TypeScript#7234:

declare function foo2<T extends readonly string[], U extends T>(
    tuples: readonly [...{ [I in keyof T]:
        [a: T[I], b: X<U[I]>]
    }]): void;

And this also works:

foo2([
    ['a', `do something here with a`], // okay
    ['b', `do something here with b`], // okay
    ['c', `do something here with c`], // okay
    // ...
    ['x', "do something here with w"], // error!
    ['y', "do nothing"], // error! 
    ['z', "do something here with z"] // okay
])

It's possible that someday (soon?) there will be an official NoInfer<T> type and then you can just use it. Until then you can use one of these alternatives.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • In the solution that uses a second type parameter, IIUC, it works because `U` will not be inferred such that it influences `T`, in fact, it seems that nothing the user inputs will cause `U` to infer? I would like to better understand when and when not does TS infer. Could you explain it a bit more here? How do you know `U` won't succumb to inference? – Jason Kuhrt Apr 02 '23 at 12:58
  • I added a little more. I don't want to digress into a full discussion about how to emulate `NoInfer`, or generally how inference works. A sketch: `U` *will* be inferred from the user input, but it will be *constrained* to `T`, and that's where the failure happens, if they're not compatible. The only possible worry is that `T extends U` would cause `T` to be inferred from the user input for `U`, but constraints are not used as inference sites, so that means `T` is only inferred from `[a: T[I],` and `U` is only inferred from `b: X]`. And if `U` is not assignable to `T`, you get an error. – jcalz Apr 02 '23 at 14:24
  • Thanks! I came across another case that I couldn't figure out, this time with objects, if you have time maybe your amazing skills can resolve that one too... : ) https://stackoverflow.com/questions/75944857/how-can-this-function-taking-object-of-types-be-typed-in-ts – Jason Kuhrt Apr 06 '23 at 00:12