I'm trying to narrow a union type using a conditional type definition but no matter how hard I try I can't comprehend why this code is failing :
const makeEventA = (value: number) => ({ _tag: "a" as const, payload: value })
type EventA = ReturnType<typeof makeEventA>
const makeEventB = (value: string) => ({ _tag: "b" as const, payload: value })
type EventB = ReturnType<typeof makeEventB>
type AnyEvent = EventA | EventB;
type AnyEventTag = AnyEvent["_tag"];
type FindByTag<A, B extends AnyEventTag> = A extends { _tag: B } ? A : never;
type Mapping = {
[K in AnyEventTag]?: (ev: FindByTag<AnyEvent, K>) => Promise<void>;
};
const execute = (m: Mapping) => (ev: AnyEvent) => {
const handler = m[ev._tag];
if (handler) {
handler(ev);
}
};
My call handler(ev)
generates this:
Argument of type 'AnyEvent' is not assignable to parameter of type 'never'.
The intersection 'EventA & EventB' was reduced to 'never' because property '_tag' has conflicting types in some constituents.
Type 'EventA' is not assignable to type 'never'.
Here is a link to a sandbox with the code => https://codesandbox.io/embed/dank-dawn-ilcxt?fontsize=14&hidenavigation=1&theme=dark&view=editor
Can someone explain to me why I'm wrong?
CodePudding user response:
The problem is that handler
(ignoring the undefined
, i.e. within the if
block),
is either a map EventA => Void<Promise>
or a map EventB => Void<Promise>
, whereas the input ev
is either of type EventA
or EventB
. So from the compiler's perspective, it could be, for example, that handler
is of type EventA => Promise<Void>
, while ev
is of type EventB
, which then are not compatible. The type FindByTag
does not reify an AnyEvent
to an actual EventA
or an EventB
based on what K
is.
CodePudding user response:
The underlying issue here is that the compiler doesn't understand the fact that the type of handler
is correlated with the type of ev
; it sees each of them as independent union types. See microsoft/TypeScript#30581 for details. So handler(ev)
is an error because the compiler is worried about impossible "cross-correlated" situations. The compiler doesn't do what you do when faced with handler(ev)
and analyze it multiple times for each possible _tag
property of ev
. If it did do this, there wouldn't be a problem; indeed, you can force the compiler to go through such analysis by actually writing out redundant code:
const executeRedundant = (m: Mapping) => (ev: AnyEvent) => {
if (ev._tag === "a") {
const handler = m[ev._tag];
if (handler) {
handler(ev); // okay
}
} else {
const handler = m[ev._tag];
if (handler) {
handler(ev); // okay
}
}
};
That's type safe, but blecch. Refactoring to redundant code doesn't scale.
If you're going to refactor your code for type safety, you could do it so that it's generic in the type of the _tag
property. You can use the approach presented in microsoft/TypeScript#47109, where you represent your AnyEvent
discriminated union as a "distributive object type":
type TagPayloadMap = {
a: number;
b: string;
}
type AnyEventTag = keyof TagPayloadMap
type AnyEvent<K extends AnyEventTag = AnyEventTag> =
{ [P in K]: { _tag: P, payload: TagPayloadMap[P] } }[K]
const makeEvent = <K extends AnyEventTag>(tag: K) => (value: TagPayloadMap[K]): AnyEvent<K> => ({ _tag: tag, payload: value });
const makeEventA = makeEvent("a");
const makeEventB = makeEvent("b");
type EventA = AnyEvent<"a">;
type EventB = AnyEvent<"b">;
type Mapping<K extends AnyEventTag = AnyEventTag> = {
[P in K]?: (ev: AnyEvent<P>) => Promise<void>;
};
const execute = <K extends AnyEventTag>(m: Mapping<K>) => (ev: AnyEvent<K>) => {
const handler = m[ev._tag];
if (handler) {
handler(ev); // okay
}
};
That all works just fine now (even in TS4.5, although it will apparently be easier to call execute()
in TS4.6 ), and the compiler sees handler(ev)
as acceptable because it's all generic in K
. The types AnyEvent
and Mapping
, etc are all the same, but depend on a TagPayloadMap
type. (Your FindByTag
functionality is subsumed by the new AnyEvent
type; you can write AnyEvent<'a'>
instead of FindByTag<AnyEvent, 'a'>
, for example.)
If you're not going to refactor your code because you are dependent on the current structure, then you're going to have to give up on having type safety. The compiler just can't see that what you're doing is safe. In such cases, you can use a type assertion to tell the compiler not to worry too much about the types. It shifts the burden of maintaining type safety away from the compiler (which is not up to the task) and onto you. So triple-check that you're doing it right, and then assert away.
For example:
const executeAssert = (m: Mapping) => (ev: AnyEvent) => {
const handler = m[ev._tag];
if (handler) {
(handler as (value: AnyEvent) => Promise<void>)(ev);
}
};
Here we're saying that handler
can be treated as a function that takes AnyEvent
; this isn't strictly true, but it's a fairly harmless lie and it convinces the compiler that it's okay to call handler(ev)
. Again, it's not safe; you can pass in something other than ev
, like a random AnyEvent
, and the compiler won't bat an eye. So you need to be careful.
So those are the options: refactor to be redundant; refactor to be generic; or use a type assertion to suppress the error.