1

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

Anzor
  • 310
  • 1
  • 8
  • 1
    You should not do this using a Map because Maps are homogeneous by nature. You could use a regular object instead, or use `Map>`. – kelsny Apr 13 '22 at 13:41
  • Why `subscribers.has(eventName)` instead of `!subscribers.has(eventName)`? Is that a typo? If so, could you fix it? If not, what are you doing? Either way, could you double check to make sure you've presented a [mre] that demonstrates your issue? – jcalz Apr 13 '22 at 13:42
  • My inclination here would be to use a plain object and use some support for correlated types added in TS4.6 to refactor. [This](https://tsplay.dev/mLl5km) is one way to do it. If that works for you I could maybe write up an answer, assuming you fix up your question first. If that doesn't work, what am I missing? – jcalz Apr 13 '22 at 14:07
  • Updated code and added codesandbox links. @jcalz, your example casts the Set to which is what I was trying to avoid (I did something similar in the first snippet) – Anzor Apr 13 '22 at 14:18
  • @kellys I think that typing the map that way would imply that you can have "CLICK" with payloads from "MOVE" and vice versa. They need to be co-dependent. – Anzor Apr 13 '22 at 14:20
  • The phrase "casting the `Set` to ``" implies something like `const x = new Set() as any` which is not what's happening; a `Set` is still a `Set`, while an `any` is not required to be. I'm assuming we agree on that, but I just want to be clear. – jcalz Apr 13 '22 at 14:41
  • The only time a `Set` shows up in my example solution is when assigning an empty set to a property that expects some kind of `Set` the compiler can't reason about. Since an empty set is conceptually assignable to any `Set` type, it can be hopefully seen as safe to all but the most skeptical humans, so the assertion is pretty mild. Can you explain how you did "something similar in the first snippet"? I'm not seeing it. – jcalz Apr 13 '22 at 14:51
  • 1
    The type safety problem in your first snippet is in the ability to write `subscribers.get(EVENT_NAME.CLICK)?.forEach(v => v({ distance: 1 }));` whereas the analogous `subscribers[EventName.CLICK]?.forEach(v => v({ distance: 1 }));` in mine is flagged as an error. The type assertion `as Set<(value: PAYLOADS[E]) => void>` in your first example is unsafe for precisely that reason, whereas the `new Set` is not unsafe. You could write `new Set` instead to mean "an empty set" and TS allows it due to collection covariance... like [this](//tsplay.dev/WKq9zN), is that more palatable to you? – jcalz Apr 13 '22 at 14:58
  • @jcalz, your solution is likely the closest I will get, and a good example of when not to use Maps and instead to use regular objects. Even with casting the set in my first snippet (`as Set<(value: PAYLOADS[E]) => void>;`), it fails to infer the type in the `forEach`, which is because there doesn't seem to be a way to tie together the Map index and value, unlike when using plain objects. Please write up your answer and I will accept it. Thanks! – Anzor Apr 13 '22 at 16:37
  • Okay I will write up an answer when I get a chance, maybe later this evening. – jcalz Apr 13 '22 at 19:49

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360