在 TypeScript 中实现 pub/sub 时限制事件名称和有效负载的最有效方法是什么

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

我正在尝试让 TypeScript 严格键入一个 pub/sub 模块,以便在用户传递不符合其 EVENT_NAME.

的有效载荷时它会大喊大叫

我的第一次尝试如下:

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);
});

据我了解,将在 EVENT_NAME 和 PAYLOAD 不相互依赖的位置创建地图,因此我尝试为地图使用交集类型,并为 on 重载功能:

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);
});

通过重载,一切正常,但非常冗长(可能有几十个 EVENT_NAMES),on 函数中的类型也丢失了 此外,将每个事件添加到 Map 交集类型感觉过于冗长。我有没有更好的方法失踪?提前致谢!

编辑:添加 codesandbox 链接和固定代码以使其运行:

Attempt 1 Attempt 2

使用 a Map object is that the TypeScript library definition for Map<K, V> 的主要问题就像一本字典,其中每个值都是同一类型 V。它不像普通对象那样是强类型的,每个特定的 属性 可以有不同的值类型。

并非不可能,但它比首先使用普通对象要复杂得多。所以让我们这样做:

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

此处 subscribersannotated as a mapped type,在 EventName 中具有键 E 和值 Set<(value: Payloads[E]) => void>。这种表示对于在 on().

的实现中维护类型安全非常重要

事实证明,要让编译器相信 on() 的实现是类型安全的是很棘手的。问题是 eventNamelistenersubscribers 相关的 ,因此像 subscribers[eventName].add(listener) 这样的单行代表多个类型操作。在 TypeScript 4.6 之前,编译器会假设这是不安全的,因为它会忘记 eventNamelistener 之间的相关性。有关详细信息,请参阅 microsoft/TypeScript#30581

幸运的是,TypeScript 4.6 引入了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以获取更多信息。


所以好消息是类型安全是可能的。坏消息是它仍然很棘手。 subscribers 一开始是空的,这意味着我们可能需要用一个空集来初始化 on() 中的属性。即使 TypeScript 4.6 有了改进,编译器仍然对 subscribers[eventName] = new Set().

不满意

这里是一个编译没有错误的版本;我不得不通过反复试验找到这个:

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)
}

首先我们将 subscribers[eventName] 读入变量 set 以查看它是否已定义。如果不是,那么我们通过 new Set<never>() 创建一个新的空集。明确指定 Set 的类型参数为 never 有点奇怪,但它或类似的东西必须被视为可分配给 set。根据定义,Set<never> 本质上是一个空集;你不能在这样的集合中放置任何实际值。编译器将 set 视为未解析的泛型类型 (typeof subscribers)[E],并且看不到 new Set() 本身可分配给它。您需要给它一个 Set<X> 类型的值,其中 X 可分配给 (typeof subscribers)[E] 的每个可能值。您 可以 将其写为所有相关类型的 intersection,但 never 也同样有效。

无论如何,一旦我们将空集分配给 set 并返回给 subscribers[eventName],编译器意识到 set 无论如何都会被定义,因此让我们调用 set.add(listener)没有错误。


这是一种继续进行的方式。

在上面的例子中,我找到了一些可以编译的东西,并且或多或少是安全的。但这并不总是可能的。

在实践中,如果您发现自己因为无法验证类型安全而与编译器发生冲突,那么偶尔使用 type assertion 并继续您的生活...只要您小心自己验证断言的类型安全。

Playground link to code