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?
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?!
// 😜 ------> ^^^^
}
For a long time that was the best one could do, or at least I thought so. But then microsoft/TypeScript#47109 made some fixes to allow a refactored version of the above to work. The idea is to explicitly represent your operations in terms of a distributive object type, where you map over keys of some mapping type and immediately index into it. This allows you to write a generic functions where all types are computed in terms of the generic key type K
of this mapping type. You can read ms/TS#47109 for more info, but here's the relevant refactoring of your above types:
interface Mapping {
FOO: {
propA: string;
},
BAR: {
propB: number;
}
}
type Mapped<K extends keyof Mapping = keyof Mapping> =
{ [P in K]: { name: P } & Mapping[P] }[K];
type Foo = Mapped<"FOO">
type Bar = Mapped<"BAR">
Your Foo
and Bar
types are subsumed into Mapped<K>
. The type Mapped<"FOO">
is equivalent to Foo
, and Mapped<"BAR">
is equivalent to Bar
. And the type Mapped
by itself is equivalent to Either
.
Then callers
should be annotated as being of a mapped type over the same keys:
const callers: { [K in keyof Mapping]: (x: Mapped<K>) => Mapped<K> } = {
'FOO': (x: Foo) => x,
'BAR': (x: Bar) => x
}
And finally, call()
is generic over the same key, and the compiler is happy:
const call = <K extends keyof Mapping>(x: Mapped<K>): Mapped<K> => {
return callers[x.name](x); // okay
}
And it will even complain if you do something wrong:
const call = <K extends keyof Mapping>(x: Mapped<K>): Mapped<K> => {
return callers.FOO(x) // error!
}
And it works well from the caller's side, too:
call({ name: "FOO", propA: "hello" }); // okay
call({ name: "FOO", propB: 123 }); // error
call({ name: "BAR", propB: 123 }); // okay
So there you go. It is possible to rewrite your code in such a way that the compiler accepts what you're doing as a single generic operation instead of as a collection of operations whose correlations it cannot track.