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
相关产品推荐
相关产品推荐

