Home > front end >  Is there a way to narrow a type at compile time?
Is there a way to narrow a type at compile time?

Time:12-29

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.

Playground link to code

  • Related