Say I have a dictionary of functions like so:
interface Base {
name: string;
}
interface Foo extends Base {
name: 'FOO',
propA: string
}
interface Bar extends Base {
name: 'BAR'
propB: number
}
const callers: { [key: string]: <T extends Base>(x: T) => T } = {
'FOO': (x: Foo) => x,
'BAR': (x: Bar) => x
}
const call = <T extends Base>(x: T): T => {
return callers[x.name](x)
}
how can I tell typescript that if this dictionary is called with the right key, we can assume that the passed in parameter is of the right type?
(tsplayground with the above code)
CodePudding user response:
As written your callers
typing is not strictly correct. The type <T extends Base>(x: T) => T
means that the caller gets to choose T
. According to that, callers.FOO
would have to accept a Bar
if the caller wanted. That's not what callers
does, so the compiler complains.
You could start to fix it by defining a union Either
of all your explicitly handled subtypes of Base
:
type Either = Foo | Bar
And then saying that callers
has a type that depends on it (say as a mapped type over Either
with remapped keys:
const callers: { [T in Either as T["name"]]: (x: T) => T } = {
'FOO': (x: Foo) => x,
'BAR': (x: Bar) => x
} // okay!
Now there's no error there, but then this is a problem:
const call = <T extends Base>(x: T): T => {
return callers[x.name](x); // error!
// can't index into callers with an arbitrary string
}
Oh, right, call()
doesn't handle arbitrary subtypes of Base
, it only handles Either
. Let's fix that by changing the generic constraint from Base
to Either
. Uh oh:
const call = <T extends Either>(x: T): T => {
return callers[x.name](x) // error!
// Either is not assignable to Foo & Bar
}
And now we're stuck. The compiler doesn't realize that the type of callers[x.name]
is correlated with the type of x
in the right way. Conceptually it seems like the compiler should just "see" that it works for "each" possible narrowing of x
from Either
. If x
is a Foo
it works. If x
is a Bar
it works. So it should work.
But that's not how the compiler looks at it. It examines the code once total, not for each narrowing. So x
is Foo | Bar
, and thus callers[x.name]
is some function which might take a Foo
or it might take a Bar
but the compiler doesn't know which. And the only safe input for such a function in general is something that's both a Foo
and a Bar
... that's a Foo & Bar
. But x
is a Foo | Bar
not a Foo & Bar
. In fact there are no possible values of type Foo & Bar
because the name
property would have to be both "FOO"
and "BAR"
and there are no strings of that type. So the compiler complains and gives up.
This problem, whereby the compiler loses track of the correlation in types between two values, especially of a function type and it input type, is the subject of microsoft/TypeScript#30581. It's also the subject of a recent twitter thread by @RyanCavanaugh.
You can just throw a type assertion at it and move on with your life:
const call = <T extends Either>(x: T): T => {
return (callers[x.name] as <T extends Either>(x: T) => T)(x) // okay
}
But this is just telling the compiler to ignore the issue. So you need to be careful not to do the wrong thing, because the compiler won't catch it.
const call = <T extends Either>(x: T): T => {
return (callers.FOO as <T extends Either>(x: T) => T)(x) // still okay?!
//