Home > Net >  What's the most efficient way to restrict event name and payloads when implementing pub/sub in
What's the most efficient way to restrict event name and payloads when implementing pub/sub in

Time:04-15

I am attempting to have TypeScript strictly type a pub/sub module, so that it yells if users pass payloads that don't adhere to their EVENT_NAME.

My first try was as follows:

enum EVENT_NAME {
  CLICK = 'CLICK',
  MOVE = 'MOVE'
}

type PAYLOADS = {
  [EVENT_NAME.CLICK]: {
    value: string
  },
  [EVENT_NAME.MOVE]: {
    distance: number
  }
}


const subscribers: Map<EVENT_NAME, Set<(value: PAYLOADS[EVENT_NAME]) => void>> = new Map();

function on<E extends EVENT_NAME>(eventName: E, listener: (value: PAYLOADS[E]) => void) {
    if (!subscribers.has(eventName)) {
        subscribers.set(eventName, new Set());
    } else {
        // Without the assertion TypeScript yells that: 
        /**
         * Argument of type '(value: PAYLOADS[E]) => void' is not assignable to parameter of type '(value: { value: string; } | { distance: number; }) => void'.
            Types of parameters 'value' and 'value' are incompatible.
                Type '{ value: string; } | { distance: number; }' is not assignable to type 'PAYLOADS[E]'.
                Type '{ value: string; }' is not assignable to type 'PAYLOADS[E]'.
                    Type '{ value: string; }' is not assignable to type '{ value: string; } & { distance: number; }'.
                    Property 'distance' is missing in type '{ value: string; }' but required in type '{ distance: number; }'.(2345)
                    */
        // Is it possible to avoid this type assertion? Or is it the only way?
        const set = subscribers.get(eventName) as Set<(value: PAYLOADS[E]) => void>;
        set.add(listener);
    }
}

on(EVENT_NAME.CLICK, ({ value }) => {
    console.log(value)
});

on(EVENT_NAME.MOVE, ({ distance }) => {
    console.log(distance);
});

As I understand it, the map will be created where EVENT_NAME and PAYLOAD aren't co-dependent, so I tried using an intersection type for the map, and overloads for the on function:

const subscribers: Map<EVENT_NAME.CLICK, Set<(value: PAYLOADS[EVENT_NAME.CLICK]) => void>> & Map<EVENT_NAME.MOVE, Set<(value: PAYLOADS[EVENT_NAME.MOVE]) => void>>  = new Map();

function on<E extends EVENT_NAME.CLICK>(eventName: E, listener: (value: PAYLOADS[E]) => void): void;
function on<E extends EVENT_NAME.MOVE>(eventName: E, listener: (value: PAYLOADS[E]) => void): void;
function on(eventName: any, listener: (value: any) => void) {
    if (!subscribers.has(eventName)) {
        subscribers.set(eventName, new Set());
    } else {
        const set = subscribers.get(eventName)!;
        set.add(listener);
    }
}

on(EVENT_NAME.CLICK, ({ value }) => {
    console.log(value);
});

on(EVENT_NAME.MOVE, ({ distance }) => {
    console.log(distance);
});

With overloads, everything works, but is quite verbose (there could be dozens of EVENT_NAMES), also types are lost inside the on function Also, adding each event to the Map intersection type feels overly verbose. Is there a better way I am missing? Thanks in advance!

Edit: Adding codesandbox links and fixed code so it runs:

Attempt 1 Attempt 2

CodePudding user response:

The main problem with using a Map object is that the TypeScript library definition for Map<K, V> is like a dictionary where every value is of the same type V. It's not strongly typed like a plain object, where each particular property can have a different value type.

It's not impossible to write your own typing for Map which behaves more like a plain object, but it's much more complicated than just using a plain object in the first place. So let's do that:

const subscribers: { [E in EventName]?: Set<(value: Payloads[E]) => void> } = {}

Here subscribers is annotated as a mapped type with keys E in EventName and values Set<(value: Payloads[E]) => void>. This representation will turn out to be important in maintaining type safety inside the implementation of on().


It turns out to be tricky to convince the compiler that an implementation of on() is type safe. The problem is that eventName, listener, and subscribers are correlated in such a way that a single line like subscribers[eventName].add(listener) represents multiple type operations. Before TypeScript 4.6, the compiler would just assume this was unsafe, because it would forget the correlation between eventName and listener. See microsoft/TypeScript#30581 for more information.

Luckily, TypeScript 4.6 introduced improvements for generic indexed accesses; as long as we represent the type of subscribers in terms of Payloads in the right way, then the compiler will be able to maintain the correlation inside the body of on(). See microsoft/TypeScript#47109 for more information.


So the good news is that type safety is possible. The bad news is that it's still tricky. The fact that subscribers starts off empty means we might need to initialize properties inside on() with an empty set. And even with the improvements in TypeScript 4.6, the compiler is unhappy with subscribers[eventName] = new Set().

Here is a version which happens to compile with no errors; I had to find this via trial and error:

function on<E extends EventName>(eventName: E, listener: (value: Payloads[E]) => void) {
    let set = subscribers[eventName];
    if (!set) {
        set = new Set<never>(); 
        subscribers[eventName] = set;
    }
    set.add(listener)
}

First we read subscribers[eventName] into a variable set to see if it's defined. If not, then we create a new empty set via new Set<never>(). Explicitly specifying the type parameter for Set as never is a bit weird, but it or something like it is necessary to be seen as assignable to set. A Set<never> is essentially an empty set by definition; you can't put any actual value in such a set. The compiler sees set as the unresolved generic type (typeof subscribers)[E], and it can't see that new Set() by itself is assignable to that. You need to give it a value of type Set<X> where X is assignable to every possible value of (typeof subscribers)[E]. You could write this as the intersection of all the relevant types, but never works just as well.

Anyway, once we assign the empty set to set and back to subscribers[eventName], the compiler realizes that set will definitely be defined no matter what, thus letting us call set.add(listener) with no error.


So that's one way to proceed.

In the above case I was able to find something that compiled and is more or less safe. But it's not always possible.

In practice if you find yourself fighting with the compiler because it can't verify type safety, it's fine to use the occasional type assertion and move on with your life... as long as you take care to verify the type safety of your assertion yourself.

Playground link to code

  • Related