TypeScript可辨识联合管理:实现支持属性方法的类实例式枚举集合
TypeScript 枚举已经接近我的需求,但仍不够完善。我需要一组编译期可知且运行期静态的条目,行为类似枚举,但属于类的实例,支持添加属性、方法等扩展功能。
本问题基于 TypeScript 4.4.3 版本提出。
示例
举个例子,假设一家小型便利店有3个货架,每个货架有id和name两个属性:
// Aisle objects name: 'food', aisleId: '4' name: 'housewares', aisleId: '7' name: 'camping', aisleId: '9'
当货架的name或aisleId存储在变量中时,Aisles类上的静态辅助方法会很实用:
static getByName(name: string): Aisle { // return the right Aisle, or return undefined, or maybe throw } static getById(aisleId: string): Aisle { // same, but for aisleId }
(可能还会有description等其他属性,但不会通过 description 查询 Aisle 对象。)
如果代码需要操作特定的Aisle,最好可以直接写Aisles.Food或者Aisles['7']。虽然可以用前面提到的 getBy 方法,但当'food'在编译期就是已知值时,还要写Aisles.getById('food')就很不方便。如果需要拆分到不同枚举中,比如AisleNames.Food或AisleIds['7']也是可以接受的。
目标
最好能实现全部或大部分以下目标:
- 仅处理传入
Aisle实例的代码可以依赖环境声明,无需导入任何内容:function cleanAisle(aisle: Aisle) {}不需要导入,因为它没有创建任何实例。 - 可以在某处暴露类似枚举的所有货架集合:比如
AisleList、Aisles或者AislesEnum。 - 环境类型声明是整个代码库类型安全的唯一权威来源。最好不要存在两处可能冲突的类型/类信息源:一处是暴露枚举或类枚举对象的 .ts 文件,另一处是声明同名枚举的环境 .d.ts 文件,两者都不是唯一权威来源。
- 出现任何不一致时要抛出类型错误:比如任何代码中新增、删除货架,名称写错、拼写错误,或者出现不该有的重复项时都要报错。
- 方案的代码重复量尽可能少。比如先定义属性名,再把相同的名字作为字符串传入构造函数,让类实例可以返回自身名称的写法就不好,例如
readonly food = new Aisle('food', '4')重复了'food',还可能出现属性和实例值不匹配的问题。理想写法是new Aisle<'food'>(),然后实际的name属性,或者代码中对名称的使用,都可以从环境类型声明或对应的类实例派生而来。
尝试过的方案
在实现这些目标的过程中遇到了几个问题:
- 尝试给
Aisle添加泛型类型参数,比如Aisle<'food'>,但要把这些实例放到集合中时,不知道该怎么定义对应的接口。比如定义了interface Aisle<T extends AisleName> {}和实现该接口的具体类ConcreteAisle<T extends AisleName> implements Aisle<T> {},就无法把ConcreteAisle<T>的实例放到Aisle[]中,因为Aisle需要传入泛型类型参数,但没有合适的单一泛型类型可以传入。如果用联合类型Aisle<AisleName>,其中type AisleName = 'food' | 'housewares' | 'camping',又会出现唯一性/完整性问题,Set<Aisle>无法保证唯一性。 - 因为
tsconfig.json要求开启"isolatedModules": true,enum Aisle as const方案不可行。 - 实现
getAisleById()和getAisleByName()方法时,用Aisle对象组成的Set遍历生成Map<AisleName, Aisle>和存储aisleId的Map,但Map<K, V>#get方法的返回类型是V | undefined,需要变通方法让TypeScript认为只要值属于联合类型,返回结果就一定不是undefined。类型守卫或类型断言可以实现,但最好不要暴露给消费者,相关逻辑应该封装在Aisles和类枚举结构内部。 - 可以接受为每个
Aisle单独写具体类,硬编码aisleName和aisleId,但这样会让“作为可辨识联合的集合中操作实例”的问题更严重。
可行实现方案
可以通过全局类型声明 + 配置表 + 常量对象的组合方案满足所有需求,不需要依赖额外插件,兼容isolatedModules: true配置。
步骤1:定义全局权威类型源
在全局.d.ts声明文件中定义唯一的类型标准:
// global.d.ts type AisleName = 'food' | 'housewares' | 'camping' type AisleId = '4' | '7' | '9' interface Aisle { name: AisleName aisleId: AisleId description?: string // 可任意扩展自定义属性、方法 getFullInfo(): string } declare const Aisles: Record<AisleName, Aisle> & { getByName<T extends AisleName>(name: T): Aisle & { name: T } getById<T extends AisleId>(id: T): Aisle & { aisleId: T } all: Aisle[] }
这一步满足了仅使用Aisle类型的代码无需导入的需求,全局类型声明是唯一的权威源,不会出现多源冲突。
步骤2:实现类枚举对象
在单独的业务文件中实现Aisles逻辑,实现会自动和全局类型校验:
// aisles.ts // 配置表天然保证name、id唯一性,拼写错误编译期直接报错 const aisleConfigs: Record<AisleName, Omit<Aisle, 'name' | 'getFullInfo'>> = { food: { aisleId: '4', description: '食品类货架' }, housewares: { aisleId: '7', description: '家居用品货架' }, camping: { aisleId: '9', description: '户外露营用品货架' } } as const // 实现Aisle类 class AisleImpl implements Aisle { public readonly name: AisleName public readonly aisleId: AisleId public readonly description?: string constructor(name: AisleName, config: Omit<Aisle, 'name' | 'getFullInfo'>) { this.name = name this.aisleId = config.aisleId this.description = config.description } getFullInfo() { return `${this.aisleId}号货架-${this.name}:${this.description || '无描述'}` } } // 自动生成实例和映射关系,无重复代码 const AislesImpl = {} as Record<AisleName, Aisle> const byNameMap = new Map<AisleName, Aisle>() const byIdMap = new Map<AisleId, Aisle>() const allAisles: Aisle[] = [] Object.entries(aisleConfigs).forEach(([name, config]) => { const aisle = new AisleImpl(name as AisleName, config) AislesImpl[name as AisleName] = aisle byNameMap.set(aisle.name, aisle) byIdMap.set(aisle.aisleId, aisle) allAisles.push(aisle) }) // 静态方法内置类型断言,消费者不需要额外处理非空判断 export const Aisles = Object.assign(AislesImpl, { getByName<T extends AisleName>(name: T) { return byNameMap.get(name) as Aisle & { name: T } }, getById<T extends AisleId>(id: T) { return byIdMap.get(id) as Aisle & { aisleId: T } }, all: allAisles })
新增/删除/修改货架仅需要调整全局联合类型和配置表,任何不一致都会触发编译错误,完全避免了name重复书写导致的不匹配问题。
步骤3:全局挂载(可选)
如果需要全局直接使用Aisles无需导入,可在项目入口文件挂载:
import { Aisles } from './aisles' declare global { const Aisles: typeof import('./aisles').Aisles } // 运行期挂载到全局 globalThis.Aisles = Aisles
方案优势
- 完全覆盖所有需求,没有冗余代码
- 支持
Aisles.food、Aisles.getByName('food')、Aisles.getById('4')多种访问方式,编译期即可校验拼写错误 - 静态方法返回值自动收窄类型,不需要消费者写额外的类型守卫
- 兼容
isolatedModules: true配置
内容的提问来源于stack exchange,提问作者ErikE

