1

Suppose we have following code:

// a value, and a function that will be called with this value
type ValueAndHandler<T> = [value: T, handler: (value: T) => void]

type Params = {
    // not sure how to type it
    valuesAndHandlers: ValueAndHandler<???>[]
    // it's not the only field in the structure
    // so I cannot just pass array as variadic arguments
    otherStuff: number
}

function doSomethingWithParams(params: Params){/* something, not important*/}

doSomethingWithParams({
    // array of some pairs of handlers and values
    // each value is only related to its handler, and not to other handlers/values
    valuesAndHandlers: [
        [5, value => console.log(value.toFixed(3))],
        ["example", value => console.log(value.length)]
    ],
    otherStuff: 42
})

In this code I want the type of parameter of first handler to be inferred as number, and type of parameter of second handler to be inferred as string; but that's not happening (because I typed them as unknown in variable definition).

How can I type the definition so proper type for each handler is inferred individually?

I tried to add a default type for generic parameter, but it's not helping, because it's still the same for all the pairs. I also tried to use satisfies without explicitly typing the variable, but that worked just the same. I have also seen this question, but could not apply that technique to my case.

Nartallax
  • 123
  • 6
  • 1
    Does [this approach](https://tsplay.dev/WG8Ykw) using mapped tuple types and a generic helper function meet your needs? If so I'll write up an answer explaining; if not, what am I missing? – jcalz Apr 11 '23 at 23:51
  • Maybe it does? But I don't see how. Updated the question. Actually the variable is not really a variable but a field in a structure. That structure is supposed to be passed in a function, but I don't see how I can make the function infer the type for me. – Nartallax Apr 12 '23 at 00:02
  • 1
    Either you have to make `Params` generic like [this shows](https://tsplay.dev/wgvB6W) (and inference suffers a little bit by having this inside a nested property, so you'll have to do a bit more annotating), or you need to refactor because there's no really good specific type that corresponds to `ValueAndHandler??>`; you'd be looking for *existentially quantified generics* and TS doesn't support them directly. You can emulate them like [this shows](https://tsplay.dev/WYeyvW) but it's a bit weird. ... – jcalz Apr 12 '23 at 00:17
  • ... What is the use case for an array of these things if you don't know what the type arguments are? Presumably you can only do `vh[1](vh[0])`, in which case you might as well just expose only that functionality like [this shows](https://tsplay.dev/NnELaw). Which one of those three approaches, if any, would you like me to write up as an answer? – jcalz Apr 12 '23 at 00:17
  • Yes, first approach is just what I need! I was able to use it. Didn't think it's possible to use tuples like that. You can write an answer and I'll accept it. Thanks a lot! – Nartallax Apr 12 '23 at 00:22
  • Which version, [this one](https://tsplay.dev/wgvB6W)? If so that's essentially the same as the very first thing I posted and also the same as adsy's answer below... and I'm not sure I want to go through the effort of posting my own answer. Or is it one of the other versions? – jcalz Apr 12 '23 at 00:24
  • Yep, this one. It's not totally the same; the key difference is that I didn't know that you can expand a mapped type into a tuple like that – Nartallax Apr 12 '23 at 00:27
  • 1
    Oh so you're just talking about using variadic tuple syntax `[...X]` instead of `X` syntax to hint that we want `X` to be inferred as a tuple type and not an unordered array? I guess that's enough of a difference to matter... I'll write up an answer when I get a chance. – jcalz Apr 12 '23 at 00:29

2 Answers2

3

If you want inference like this you don't have much choice other than to spin up a wrapper fn :


function valueAndHandlers<T extends any[]>(
  ...arg:{ [I in keyof T]: [T[I], (arg: T[I]) => void] }
) {
  return [arg]
}

// This will return them wrapped in an outer array. Need to use varargs to get inference I think.

valueAndHandlers(
  [5, value => console.log(value.toFixed(3))],
  ["example", value => console.log(value.length)]
) 

// As used in your edit

doSomethingWithParams({
    // array of some pairs of handlers and values
    // each value is only related to its handler, and not to other handlers/values
    valuesAndHandlers: valueAndHandlers(
        [5, value => console.log(value.toFixed(3))],
        ["example", value => console.log(value.length)]
    )
})
adsy
  • 8,531
  • 2
  • 20
  • 31
  • Oh well. I was hoping that I'm missing something. This all is more a matter of convenience; this function will be an interface of a library, so I don't want to make all users of my library call some functions just to pass parameters properly. I'd think of another way of passing this values and handlers I guess. – Nartallax Apr 12 '23 at 00:15
  • This is quite a common approach (wrapper FN). Usually, for users of a lib it's optional to use and just if they want intellisense. Of course as well as this you need a conditional type on `doSomethingWithParams` to ensure its *validated* to ensure they cant use anything when they don't use the wrapper (will show TS error if non-matching tuples, but no inference). – adsy Apr 12 '23 at 00:16
1

Assuming you actually want to keep track of the tuple of generic type arguments for each element of your array, then you have to make Params generic in that tuple type:

type Params<T extends any[]> = {
  valuesAndHandlers: [...{ [I in keyof T]: ValueAndHandler<T[I]> }]
}

Here the valuesAndHandlers property is a mapped tuple type where each element T[I] of the input type T (at index I) is wrapped with ValueAndHandler to become ValueAndHandler<T[I]>. I've also wrapped the whole mapped tuple type in a variadic tuple type like [...{ ⋯ }]; this doesn't really change the type, but it does give the compiler a hint that when it infers the type it should try to infer a tuple instead of an unordered arbitrary-length array type (this is described in microsoft/TypeScript#39094: "The type [...T], where T is an array-like type parameter, can conveniently be used to indicate a preference for inference of tuple types").

To cut down on the amount of type annotations you need to write, we can make a helper function to infer the particular T argument given some Params<T>:

const asParams = <T extends any[]>(p: Params<T>) => p;

And we can use it like this:

let params = asParams({
  valuesAndHandlers: [
    [5, (value: number) => console.log(value.toFixed(3))],
    ["example", (value: string) => console.log(value.length)]
  ]
});
/* let params: Params<[number, string]> */

So params has been inferred as Params<[number, string]> as desired.

Do note that in order for this to work I had to annotate the parameter types in the callbacks. The sort of contextual typing that would infer the type of value in value => console.log(value.length) from its context doesn't work with inference from mapped tuples down inside properties. Depending how much you care this could be mitigated by refactoring the asParams() function to accept the valuesAndHandlers property value as a separate argument... or really, any number of possible refactorings to tweak inference.

The main conceptual point here is that Params needs to be generic, and the rest of it is mostly just techniques to try to get desirable inference.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360