Home > OS >  Typescript event listener - type to props mapping
Typescript event listener - type to props mapping

Time:05-11

export enum GameEventType {
  EVENT_ONE = 'event-one',
  EVENT_TWO = 'event-two',
  EVENT_THREE = 'event-three'
}

export type GameEvent =
  | { type: GameEventType.EVENT_ONE; parameter: string }
  | { type: GameEventType.EVENT_TWO; anotherParam: SomeType }
  | { type: GameEventType.EVENT_THREE; };

type GameEventListener = (event: GameEvent) => void;

export class GameObserver {
  private listenerMap = new Map<GameEventType, GameEventListener[]>();

  public on(eventType: GameEventType, listener: GameEventListener) {
    const existing = this.listenerMap.get(eventType) ?? [];
    existing.push(listener);
    this.listenerMap.set(eventType, existing);
  }

  public off(eventType: GameEventType, listener: GameEventListener) {
    let existing = this.listenerMap.get(eventType) ?? [];
    if (existing.length) {
      existing = existing.filter((l) => l !== listener);
      this.listenerMap.set(eventType, existing);
    }
  }

  public fireEvent(event: GameEvent) {
    const listeners = this.listenerMap.get(event.type) ?? [];
    listeners.forEach((l) => l(event));
  }
}

Consider the above event listener class. When you register an event, you provide the event type you care about and a callback to receive the event object. When firing the event, by giving the type property the correct GameEventType enum value, it will suggest the other props of that event object. Nice.

What's not so nice is that the consumer still has to ensure the event type is as expected in order to gain access to the other props in the object. Even though the consumer has registered to receive events for GameEventType.EVENT_ONE, and even though it will only ever receive an event when that event type is fired, the consumer still has to say if (event.type === GameEventType.EVENT_ONE) in order to get to the props in the whole event object.

gameObserver.on(GameEventType.EVENT_ONE, (event: GameEvent) => {
  // This won't work - cannot access other props of event object
  console.log(event.parameter)  
  
  // Must do this
  if (event.type === GameEventType.EVENT_ONE) {
    // In order to see the other props linked to that enum value under type
    console.log(event.parameter)
  }
});

What I'd really like:

  • when registering an event listener via on method, it will warn against assigning a callback function whose parameters don't match the props of the event
  • when firing the event, it knows the event object props from the event type and auto completes/suggests those prop names as you type
  • when consuming, the callback just has the props of the event and not the type (we know the type, we registered this callback against it!) and therefore doesn't have to check the event type in order to get the props

I have managed the latter point in a different fashion, using any, which lost out on the first two points (and would prefer to avoid any).

Any advice on this problem would be much appreciated! Thanks for reading.

CodePudding user response:

At a minimum, you'll want to make the on() and off() methods generic in the type K of the event, so that the compiler can represent the connection between the first and second arguments. Let's turn GameEvent into GameEvent<K>:

export type GameEvent<K extends GameEventType> = Extract<
  | { type: GameEventType.EVENT_ONE; parameter: string }
  | { type: GameEventType.EVENT_TWO; anotherParam: SomeType }
  | { type: GameEventType.EVENT_THREE; }, { type: K }>

This is using the Extract<T, U> utility type to filter the original union to just the member whose type property is K. So a GameEvent<GameEventType.EVENT_TWO> will specifically be {type: GameEvenetType.EVENT_TWO; anotherParam: SomeType}. And the corresponding change to GameEventListener is:

type GameEventListener<K extends GameEventType> = 
  (event: GameEvent<K>) => void;

Now we can use K in on() and off():

public on<K extends GameEventType>(eventType: K, listener: GameEventListener<K>) {
   // same implementation
}

public off<K extends GameEventType>(eventType: K, listener: GameEventListener<K>) {
   // same implementation
}

You can verify that this behaves as desired when you call one of these methods:

const gameObserver = new GameObserver()
gameObserver.on(GameEventType.EVENT_ONE, (event) => {
  console.log(event.parameter.toUpperCase()) // okay
});

You do have to tweak your other code a bit to deal with these generics. The trickiest place is in your listenerMap property, which uses a Map object. Unfortunately, a Map<K, V> typings for TypeScript gives each property the same type V no matter what key in K you're using. The least amount of work but least amount of safety is to give it a type like Map<GameEventType, GameEventListener<any>[]> and not worry about potential mismatches:

private listenerMap = new Map<GameEventType, GameEventListener<any>[]>(); 

And any other place that refers to GameEvent or GameEventListener needs to be modified accordingly:

public fireEvent<K extends GameEventType>(event: GameEvent<K>) { /*impl */ }

That's the "minimum" refactoring, which changes just the typings without changing any runtime code, and gives up some type safety whenever something touches the listenerMap. For example, inside of on(), you can replace get(eventType) with some specific type like get(GameEventType.EVENT_THREE) and the compiler has no idea that there's a problem with that.

const existing = this.listenerMap.get(GameEventType.EVENT_THREE) ?? 
  []; // no error, oops

If you really care about type safety, you can look into giving a stronger type to Map, but it's much easier to just use a plain old object type like {[K in GameEventType]?: GameEventListener<K>[], and refactor the other types to be distributive object types as coined in microsoft/TypeScript#47109 to deal with correlated generics effectively:

interface GameEventMap {
  [GameEventType.EVENT_ONE]: { parameter: string };
  [GameEventType.EVENT_TWO]: { anotherParam: SomeType };
  [GameEventType.EVENT_THREE]: {}
}
type GameEvent<K extends GameEventType> = { [P in K]: GameEventMap[P] & { type: P } }[K]
type GameEventListener<K extends GameEventType> = (event: GameEvent<K>) => void;
type ListenerMap<K extends GameEventType> = { [P in K]?: GameEventListener<K>[] };

And then listenerMap would be of type ListenerMap<K> for any K you care about:

export class GameObserver {
  private listenerMap: ListenerMap<GameEventType> = {}

  public on<K extends GameEventType>(eventType: K, listener: GameEventListener<K>) {
    const listenerMap: ListenerMap<K> = this.listenerMap;
    let listeners = listenerMap[eventType];
    if (!listeners) {
      listeners = [];
      listenerMap[eventType] = listeners;
    }
    listeners.push(listener);
  }

  public off<K extends GameEventType>(eventType: K, listener: GameEventListener<K>) {
    const listenerMap: ListenerMap<K> = this.listenerMap;
    let listeners = listenerMap[eventType];
    if (!listeners) {
      listeners = [];
    }
    listeners = listeners.filter(l => l !== listener);
    listenerMap[eventType] = listeners;
  }

  public fireEvent<K extends GameEventType>(event: GameEvent<K>) {
    const listenerMap: ListenerMap<K> = this.listenerMap;
    let listeners = listenerMap[event.type];
    if (!listeners) {
      listeners = [];
    }
    listeners.forEach((l) => l(event));
  }
}

That's sort of the "maximum" refactoring, where the compiler will be more able to catch type errors inside your class methods, like the following line analogous to listenerMap.get(GameEventType.EVENT_THREE) from before:

let listeners = listenerMap[GameEventType.EVENT_THREE]; // error!

Playground link to code

  • Related