Search code examples
typescriptgenericspolymorphismtypescript-generics

Automatic type conversion of generic for dictionary of functions


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)


Solution

  • 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.

    Playground link to code