Issue
I made a match
function that takes a value and an array of [case, func]
, the value is compared to each case
and if a match is found its associated func
is called with the value as a parameter:
type Case<T> = [T | Array<T>, (value: T) => void];
const match = <V>(value: V, cases: Array<Case<V>>) => {
for (const [key, func] of cases) {
if (Array.isArray(key) ? key.includes(value) : key === value) {
return func(value);
}
}
};
I want to make the value of each func
narrows to its associated case
, i.e:
// const value: string = "whatever";
match(value, [
[
"something",
(v) => {
// here, `v` should narrow to `"something"`
}
],
[
["other", "thing"],
(v) => {
// here, `v` should narrow to `"other" | "thing"`
}
]
] as const);
Solution
You can write the correct typings for match()
but it will not infer the callback parameter types the way you'd like. This is a missing feature in TypeScript. You'll either need to wait for that feature to be implemented (which might be never), refactor in some way, or give up.
The correct typing for match()
looks like this:
const match = <V, C extends V[]>(
value: V,
cases: [...{ [I in keyof C]: Case<C[I]> }]
) => {
for (const [key, func] of cases) {
if (Array.isArray(key) ? key.includes(value) : key === value) {
return func(value as never);
}
}
};
here the type of cases
is not just Array<Case<V>>
, which wouldn't try to keep track of the individual subtypes of V
at each element of the array, and which furthermore isn't even correct for subtypes... for example, Case<"something">
is not assignable to Case<string>
, because although you can assign "something"
to string
, you cannot assign (value: "something") => void
to (value: string) => void
, due to the fact that function types are contravariant in their inputs.
Instead, the type of cases
is a mapped array type over the new generic type parameter C
which is constrained to a subtype of V[]
. Each element of C
is wrapped with Case<⋯>
to get the corresponding element of cases
. So if C
is specified by ["x", "y", "z"]
, then the type of cases
is [Case<"x">, Case<"y">, Case<"z">]
.
The hope is that if you call
const value: string = "whatever";
match(value, [
[ "something", (v) => {} ],
[ ["other", "thing"], (v) => { }]
]);
the compiler would infer V
to be string
, which does happen... and it would also infer C
to be [ "something", "other" | "thing" ]
, which unfortunately does not happen. And even if it did happen, the contextual typing of v
in those callbacks wouldn't manage to happen in time.
It's difficult for the type checker to infer a generic type argument like C
while at the same time inferring a contextual type for function parameters like c
, when they are mutually dependent. The currently open issue about this phenomenon in general is microsoft/TypeScript#47599. For match()
in particular the most applicable issue is probably microsoft/TypeScript#53018, since we're trying to infer C
from a mapped type (inference from mapped types is sometimes called a "reverse" mapped type) and then contextually infer v
in each element.
For now this isn't implemented, and so the above call ends up failing to infer C
properly. It's just string[]
, which means that both instances of v
are of type string
, which isn't what you want.
I don't know whether microsoft/TypeScript#53018 will ever be implemented, or even necessarily that the above code will work directly if it is. For now all we can do is give up or work around it.
There are lots of possible workarounds; the easiest one might be to use a helper function inside the call to match()
to infer your cases one at a time instead of all at once. Something like this:
match(value, [
c("something", (v) => { }),
c(["other", "thing"], (v) => { })
]);
where c
is defined as
const c = <const T,>(
value: T | Array<T>,
handler: (value: T) => void
): Case<T> => [value, handler];
This works; the first v
is inferred as "something"
and the second v
is inferred as "other" | "thing"
, and thus C
in the call to match()
is inferred as ["something", "other"|"thing"]
, as desired. Each call to c
only needs to worry about inferring one thing, not a whole array of things.
Yes, this is just a workaround, but it's fairly lightweight; all you need to do is replace [xxx,yyy]
with c(xxx,yyy)
, which is only one extra keystroke. Yes, there's some cognitive overhead, so that's why this is a workaround and not a direct solution.
Whether this or some other workaround is acceptable or not depends on your use cases, and since the question doesn't ask for workarounds then this is probably out of scope for the question as asked, so I'll stop now.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.