You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

TypeScript泛型优化:确保事件名称属于指定接口类型

问题:TypeScript泛型事件类无法校验未声明的事件名

我正在编写一个class,想用generic实现指定interface中每个key + value的类型安全。目前大部分功能正常,但没法确保传入的key属于该interface——也就是说,泛型允许传入接口里不存在的string作为事件名。

以下是极简版本的代码:

type Listener<V = unknown[]> = (...values: V[]) => void;
type EventMap = Record<string, Listener>;

type Library<T> = Partial<Record<keyof T, Set<T[keyof T]>>>;

class EventBlaster<T extends EventMap> {
  lib: Library<T> = {};

  register<K extends keyof T>(eventName: K, listener: T[K]) {}
  trigger<K extends keyof T>(eventName: K, ...values: Parameters<T[K]>) {}
}

///
/// 使用示例

type CustomMap = EventMap & {
  change(value: string): void;
};

const customEvents = new EventBlaster<CustomMap>();

// 正常工作的注册/触发
customEvents.register('change', (value) => {});
customEvents.trigger('change', 'hello');

// TypeScript能正确抛出监听器参数错误
customEvents.trigger('change', '1st string', '2nd string');

// TypeScript无法对未声明的事件名抛出错误
customEvents.register('nope', () => {});
customEvents.trigger('nope');

在上述示例中,listener的参数校验已正常工作,但调用register/trigger时传入未声明的事件名(如'nope'),TypeScript不会抛出错误。我需要解决该问题,同时希望定义接口时无需每次都extend EventMap。


解决方案

1. 重构类型约束,移除宽泛的键声明

问题根源在于EventMap使用了Record<string, Listener>,这会让TypeScript认为该类型包含所有字符串键,导致keyof T被推断为宽泛的string类型,无法限制到仅显式声明的事件名。我们需要重新定义类型,只约束值的类型,不预设所有字符串键:

// 修正Listener类型,直接使用参数数组而非嵌套数组
type Listener<V extends unknown[] = unknown[]> = (...values: V) => void;

2. 严格约束泛型的键范围

修改EventBlaster的泛型约束,让T仅包含自身声明的键,同时确保所有值都是Listener类型:

class EventBlaster<T extends Record<keyof T, Listener>> {
  lib: Partial<Record<keyof T, Set<T[keyof T]>>> = {};

  register<K extends keyof T>(eventName: K, listener: T[K]) {
    if (!this.lib[eventName]) {
      this.lib[eventName] = new Set();
    }
    this.lib[eventName]!.add(listener);
  }

  trigger<K extends keyof T>(eventName: K, ...values: Parameters<T[K]>) {
    this.lib[eventName]?.forEach(listener => listener(...values));
  }
}

3. 简化自定义事件类型的定义

现在定义自定义事件类型时,无需再和EventMap联合,直接声明对象类型即可:

type CustomMap = {
  change(value: string): void;
  update(id: number, data: Record<string, unknown>): void;
};

const customEvents = new EventBlaster<CustomMap>();

// 正常工作
customEvents.register('change', (value) => console.log(value));
customEvents.trigger('change', 'hello');

// 参数错误会被TypeScript捕获
customEvents.trigger('change', 123); // 类型错误:number无法赋值给string

// 未声明的事件名会直接报错
customEvents.register('nope', () => {}); // 类型错误:'nope'不属于keyof CustomMap
customEvents.trigger('nope'); // 类型错误:'nope'不属于keyof CustomMap

原理说明

  • 原Record<string, Listener>的宽泛约束让TypeScript默认所有字符串都是合法键,修改为T extends Record<keyof T, Listener>后,keyof T会被严格限制为T中显式定义的键。
  • 自定义事件类型无需再继承或联合EventMap,直接声明对象类型即可,简化了定义流程。

内容的提问来源于stack exchange,提问作者beefchimi

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.03 01:00:59