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