Search code examples
typescript

Select callback from dictionary using argument in TypeScript in higher-order function


I have a function that takes an object of callbacks and an input object with a type property that matches a key of the callbacks object. The function calls the matching callback with the input object. I want to create a higher order function that takes the callbacks object and returns a function that takes a matching input object. However, I get a TypeScript error when I call the returned function which states that the input object is not assignable to a never type.

type Actor <K extends string, T> = (input: { type: K } & T) => void
type Actors <K extends string, T> = { [Key in K]: Actor<K, T> }
type TfromA<K extends string, A extends Actors<K, any>> =
  A extends Actors<K, infer T> ? T : never

export function marion<
  K extends string,
  A extends Actors<K, TfromA<K, A>>,
> (
  actors: A,
  input: { type: K } & TfromA<K, A>
): void {
  const actor = actors[input.type]
  actor(input)
}

interface Alpha { type: 'alpha', count: number }
interface Beta { type: 'beta', label: string }
const alphaBeta = {
  alpha: (input: Alpha) => console.log(input.count),
  beta: (input: Beta) => console.log(input.label)
}
const alpha: Alpha = { type: 'alpha', count: 42 }
marion(alphaBeta, alpha) // No errors

function higher<
  K extends string,
  A extends Actors<K, TfromA<K, A>>,
> (
  actors: A
): (input: TfromA<K, A> & { type: K }) => void {
  return function (input: TfromA<K, A> & { type: K }): void {
    marion(actors, input)
  }
}
const lower = higher(alphaBeta)
lower(alpha)
// Argument of type 'Alpha' is not assignable to parameter of type 'never'.
// The intersection 'Alpha & Beta & { type: string; }' was reduced to 'never' because property 'type' has conflicting types in some constituents.ts(2345)

How can I create a reusable function that handles any matching set of callbacks and input objects and can be used to create higher order functions?

Playground: https://tsplay.dev/w2844m


Solution

  • There are a few problems with the code in the question. Let's look at a version that works, and how it works, and how it differs from the version in the question:

    type Input<K, V> = { type: K } & V
    type Actor<K, V> = (input: Input<K, V>) => void
    type Actors<T extends object> =
      { [K in keyof T]: Actor<K, T[K]> }
    
    export function marion<T extends object, K extends keyof T>(
      actors: Actors<T>, input: Input<K, T[K]>
    ): void {
      const actor = actors[input.type]
      actor(input)
    }
    
    function higher<T extends object>(
      actors: Actors<T>
    ): <K extends keyof T>(input: Input<K, T[K]>) => void {
      return input => {
        marion(actors, input)
      }
    }
    

    I've found it convenient to give a type alias of Input<K, V> to {type: K} & V, since that type shows up multiple times. The Actor<K, V> type is basically the same, but the Actors mapped type is generic only in T, the object type you're transforming. Each property at key K is Actor<K, T[K]> (where T[K] is the value of the property of T at key K, which is why I used V for the type parameter).

    Then, marion() is generic in that object type T and the particular input key K. When you call marion(), TypeScript can infer T from the actors argument of type Actors<T>. You don't need to explicitly write a TfromA utility type for that to work. Note that actors[input.type] is of type Actors<T>[K], which evaluates to Actor<K, T[K]>, and since input is of type Input<K, T[K]>, you can call actors[input.type](input).

    Now the higher() function is easy enough to write because it's just a curried version of marion(). It accepts an argument actors of type Actors<T>, so only needs to be generic in T. Then it returns another generic function, which has the type parameter K.

    All of this works when you call it with your examples also:

    marion(alphaBeta, alpha); // okay
    marion(gammaDelta, gamma); // okay
    const lower = higher(alphaBeta); // okay
    lower(alpha) // okay
    

    The reasons your original version didn't work:

    • The Actors<K extends string, T> type is unhelpful because each { [Key in K]: Actor<K, T> } means that every single property is of type Actor<K, T>, even if K turns out to be a union of keys, and we can't be sure in advance what K will be. By making it a mapped type over every property of T we are well-positioned not to worry about K until examining the input argument.

    • Your TfromA<K, A> is difficult for TypeScript to reason about generically. It's implemented as a conditional type, so inside the functions marion and higher, TypeScript has no idea what TfromA<K, A> might actually be. Since TypeScript knows how to infer T from A by itself, we don't need to worry about this.

    • Your higher() function is generic in K and A but it takes no input of type K. Instead it only takes an input of type A. And while A is constrained to a type related to K, TypeScript cannot infer type arguments from constraints (see microsoft/TypeScript#7234). And you don't even want to infer K there, since you won't know K until after the returned function is called. So K just fails to infer, falls back to string, and then you get TfromA<string, typeof alphaBeta> which is just never because Actors<K, T> throws all the properties in one bag. That makes higher() return (input: never) => void which is essentially uncallable.

    Playground link to code