typescripttypes

TS generic eventHandler with match function


TS playground

Which TS rule I broke? How can I rewrite?

interface Event1 {
    name: 'Event1'
    age: number
}

interface Event2 {
    name: 'Next2'
    lastName: string
}

type UnionEvent = Event1 | Event2


interface EventHandler<T extends UnionEvent> {
  matcher: (node: UnionEvent) => node is Extract<UnionEvent, T>;
  handler: (node: T) => void;
}
const listeners: EventHandler<UnionEvent>[] = [];

export const addEventHandler = <T extends UnionEvent>(
  handler: EventHandler<T>
) => {
  listeners.push(handler);
                    // ^ Argument of type 'EventHandler<T>' is not assignable to parameter of type 'EventHandler<UnionEvent>'. 
};

Next variant with any, but it is not look as solution

Ts playground

                                            // any
interface EventHandler<T extends UnionEvent = any> {
  matcher: (node: UnionEvent) => node is Extract<UnionEvent, T>;
  handler: (node: T) => void;
}

// array of any 👎
const listeners: EventHandler[] = [];

export const addEventHandler = <T extends UnionEvent>(
  handler: EventHandler<T>
) => {
  listeners.push(handler);
};

I tried to use inherit from base event class

  interface BaseEvent {
    name: string
  }

interface Event1 extends BaseEvent {
    name: 'Event1'
    age: number
}

interface Event2 extends BaseEvent {
    name: 'Next2'
    lastName: string
}

interface EventHandler<T extends BaseEvent> {
  matcher: (node: UnionEvent) => node is Extract<UnionEvent, T>;
  handler: (node: T) => void;
}
const listeners: EventHandler<BaseEvent>[] = [];

export const addEventHandler = <T extends BaseEvent>(
  handler: EventHandler<T>
) => {
  listeners.push(handler);
                    // ^ Argument of type 'EventHandler<T>' is not assignable to parameter of type 'EventHandler<UnionEvent>'. 
};


Solution

  • The entire error message looks like:

    Argument of type 'EventHandler<T>' is not assignable to parameter of type 'EventHandler<UnionEvent>'.
      Types of property 'handler' are incompatible.
        Type '(node: T) => void' is not assignable to type '(node: UnionEvent) => void'.
          Types of parameters 'node' and 'node' are incompatible.
            Type 'UnionEvent' is not assignable to type 'T'.
              'UnionEvent' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'UnionEvent'.
                Type 'Event1' is not assignable to type 'T'.
                  'Event1' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'UnionEvent'.(2345)
    

    The last line is important: 'Event1' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'UnionEvent'.

    To understand this message, it is helpful to think about subtypes in terms of sets - all values of the subtype belong to supertype.

    I can easily find 2 subtypes of UnionEvent - Event1 and Event2.

    In your example, you passed an event handler:

    addEventHandler({
        matcher(event): event is Extract<UnionEvent, {name: 'Next2'}>
        {
            return true
        },
        handler(event2) {
            console.log(event2.name)
        }
    })
    

    which is an EventHandler<Event2> and you are trying to store in a list of EventHandler<Event1 | Event2>

    The compiler objects, and rightfully so - imagine the following code:

    listeners.forEach(listener => listener.handler({name: 'Event1', age: 1}));
    

    You stored an object with a function which can only handle Event2, but Event1 happened, and this causes a runtime error. To prevent it, the compiler prevents the store.

    Now, you have the extra knowledge that the call to handler will be preceded by a call to match, and handler will only be called when the type is correct.

    You can pass this knowledge to compiler:

    type EventHandlerWithTypeTest = (node: UnionEvent) => void;
    
    const makeHandlerWithTypeTest = <T extends UnionEvent>(handler: EventHandler<T>) => {
      return (node: UnionEvent): void => {
        if (handler.matcher(node)) {
          handler.handler(node);
        }
      }
    }
    
    const listeners: EventHandlerWithTypeTest[] = [];
    
    const addEventHandler = <T extends UnionEvent>(
      handler: EventHandler<T>
    ) => {
      listeners.push(makeHandlerWithTypeTest(handler));
    };
    

    TS Playground