Sorry for the poor title, but I'm not sure how to describe the issue I'm seeing. My use case is fairly complicated, but I'll do my best to distill it down to the bare essentials.
I have a list of config objects which have an eventName
whose value corresponds to the name of an event which can be triggered in my application. When said events are triggered, any listeners are called with an event
object of the appropriate type.
E.g.:
type EventHandler<E> = (event: E) => void;
// Just a map of eventname -> handler (with appropriate event type as an arg)
type EventMap = {
onStart: EventHandler<StartEvent>;
onEnd: EventHandler<EndEvent>;
};
const configs = [
buildConfig('onStart'),
buildConfig('onEnd')
];
I then want to map over this config object and construct another list of (different) config object who have a handlerFactory
property which is a function that builds the appropriate event handler for the event. The reason this is a function is to provide a closure which can supply additional context to the event handler.
type AdditionalContext = unknown; // Actual type is not important for this example
type HandlerConfig<E extends keyof EventMap = keyof EventMap> = {
eventName: E;
handlerFactory: (context: AdditionalContext) => EventMap[E];
};
The issue is that in my .map
callback TS is unable to infer the proper event type:
const handlerConfigs: HandlerConfig[] = configs.map(({ eventName }) => {
return {
eventName,
handlerFactory: (context) => {
// FIXME: Why is e `any`? It should be the event type that corresponds to the eventName event handler.
return (e) => {
console.log('Did something cool!', context);
};
}
};
});
Here's a TS playground showing the error.
Is what I'm trying to do even possible?
Any help would be greatly appreciated!
UPDATE: added the type definition for EventHandler
to the code above.
The main problem with
const handlerConfigs: HandlerConfig[] = configs.map(({ eventName }) => {
return {
eventName,
handlerFactory: (context) => {
return (e) => { // error, implicit any
console.log('Did something cool!', context);
};
}
};
});
is that inside the callback function, the type of eventName
is a union. If you could narrow eventName
to each member of the union and check it separately, you'd have a chance to get desired behavior:
configs.map(({ eventName }) => {
if (eventName === "onEnd") {
return {
eventName,
handlerFactory: (context) => {
return (e) => {
console.log('Did something cool!', context);
};
}
} satisfies HandlerConfig<"onEnd">;
} else return {
eventName,
handlerFactory: (context) => {
return (e) => {
console.log('Did something cool!', context);
};
}
} satisfies HandlerConfig<"onStart">;
});
But that's redundant. You'd think the compiler could just check a single code block against each possible narrowing, keeping track of the correlation between union members across different lines of code, but that's not how it works. (See https://twitter.com/SeaRyanC/status/1544414378383925250 )
This is the subject of microsoft/TypeScript#30581.
The easiest thing you can do is just give up on the compiler checking accurately and widen things or assert things until it stops complaining. For example:
configs.map(({ eventName }) => {
const ret: HandlerConfig = {
eventName,
handlerFactory: (context) => {
return (e: StartEvent | EndEvent) => {
console.log('Did something cool!', context);
};
}
};
return ret;
});
That works because (x: StartEvent | EndEvent) => void
satisfies both event handlers.
On the other hand, if you need the compiler to follow logic like this, you can take the approach recommended in microsoft/TypeScript#47109, where you use generics instead of unions.
The idea is to write operations in terms of some "basic" key-value mapping type, and generic indexes into either that basic type, or into mapped types over that type.
The goal is that you want things that would normally look like unions to look like a single generic type. Here's how I'd do it for this example code. The basic type looks like:
interface EventMap {
onStart: StartEvent,
onEnd: EndEvent
}
(note that I called this EventMap
, and renamed your EventMap
to HandlerMap
, since it's more descriptive):
type HandlerMap<K extends keyof EventMap = keyof EventMap> =
{ [P in K]: (event: EventMap[P]) => void }
The type HandlerMap
is the same as before (as your EventMap
), but now it's generic, so HandlerMap<K>
is just the piece of the object with the key K
.
And we have to change HandlerConfig
too:
type HandlerConfig<K extends keyof EventMap = keyof EventMap> = {
eventName: K;
handlerFactory: (context: AdditionalContext) => HandlerMap<K>[K];
};
That HandlerMap<K>[K]
is similar to (event: EventMap[K]) => void
except that it distributes over unions in K
. That is, it's a distributive object type as coined in microsoft/TypeScript#47109. This assumes that if K
is a union, then handlerFactory
should also be a union. If you don't care about that, you can just write
type HandlerConfig<K extends keyof EventMap = keyof EventMap> = {
eventName: K;
handlerFactory: (context: AdditionalContext) => (event: EventMap[K]) => void;
};
but that ends up just blurring the distinction between the two union members, and you couldn't write a version that actually changed based on which event type you got.
Anyway, from here we can finally write handlerConfigs
by making the map()
callback generic, saying that it coverts an EventConfig<K>
into a HandlerConfig<K>
:
const handlerConfigs: HandlerConfig[] = configs.map(
<K extends keyof EventMap>({ eventName }: EventConfig<K>): HandlerConfig<K> => {
return {
eventName,
handlerFactory: (context) => {
return (e) => {
console.log('Did something cool!', context);
};
}
};
});
Now when you inspect e
, you'll see it's EventMap[K]
, the type you expect it to be (and you could, if you want, delegate it to something that depends on eventName
).
Is it worth that refactoring? Not obviously from this use case. It's so easy to just annotate e
with something wide and move on. But in some situations, this refactoring is the only way to come close to type safety.