Home > Back-end >  Automatic type conversion of generic for dictionary of functions
Automatic type conversion of generic for dictionary of functions

Time:08-06

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?!
  //             
  • Related