如何通过部分检查在TypeScript中收窄复杂联合类型?
问题背景
你有一套TypeScript类型定义,用于描述带有特定animations或points子集的Sprite类型,原本通过hasAnimation方法可以正确收窄联合类型,但当给其中一个Register类型添加points属性后,类型收窄功能失效。
初始可正常工作的代码
type Register = { animations?: string; points?: string; } type OrString<T, K extends keyof T> = T[K] extends string ? T[K] : string; type RegisterAnimation<T extends Register> = OrString<T, 'animations'>; type RegisterPoint<T extends Register> = OrString<T, 'points'>; export interface Sprite<R extends Register> { setAnimation(animation: RegisterAnimation<R>): void; getPoint(point: RegisterPoint<R>): [number, number]; hasAnimation<T extends RegisterAnimation<Register>, U extends T>(this: Sprite<Register & { animations: T }>, animation: U): this is Sprite<Register & { animations: U }>; } // 初始类型定义 type A = { animations: 'walk' | 'static'; points: 'topLeft' | 'topRight' }; type B = { animations: 'run' | 'jump' }; type PossibleSprites = | Sprite<A> | Sprite<B> // 测试函数可正常收窄类型 function test(sprite: PossibleSprites): void { if (sprite.hasAnimation('run')) { // sprite 被正确收窄为 Sprite<B> sprite.setAnimation('run'); // 会报错:"walk" 不能赋值给 "run" | "jump" sprite.setAnimation('walk'); } else { // sprite 被正确收窄为 Sprite<A> sprite.setAnimation('walk'); sprite.getPoint('topLeft'); // 会报错:"run" 不能赋值给 "walk" | "static" sprite.setAnimation('run'); } }
类型收窄失效的场景
当给B类型添加points属性后,hasAnimation无法再正确收窄类型:
// 修改后的类型定义 type A = { animations: 'walk' | 'static'; points: 'topLeft' | 'topRight' }; type B = { animations: 'run' | 'jump'; points: 'bottom' }; // B现在包含points type PossibleSprites = | Sprite<A> | Sprite<B> // 测试函数类型收窄失效 function test(sprite: PossibleSprites): void { if (sprite.hasAnimation('run')) { // sprite 变成了 PossibleSprites & Sprite<Register & { animations: "run"; }> sprite.setAnimation('run'); // 报错:"walk" 不能赋值给 "run" sprite.setAnimation('walk'); } else { // sprite 仍然是 PossibleSprites // 报错:"walk" 不能赋值给 never sprite.setAnimation('walk'); // 报错:"topLeft" 不能赋值给 never sprite.getPoint('topLeft'); // 报错:"run" 不能赋值给 never sprite.setAnimation('run'); } }
原因分析
原hasAnimation的类型守卫仅约束了animations属性,返回的是Sprite<Register & { animations: U }>,但当A和B都包含points属性时,TypeScript无法将这个泛型类型与Sprite<A>或Sprite<B>精确匹配——它认为可能存在同时满足animations: 'run'和任意points的类型,因此无法完成联合类型的收窄。
虽然逻辑上A和B的animations完全互斥,但TypeScript需要明确的类型映射关系才能完成推断,原类型守卫没有提供足够的信息让编译器关联到具体的联合成员。
解决方案
重构hasAnimation的类型守卫,让它基于所有可能的Register类型联合,通过Extract类型找到与传入动画值匹配的具体Register类型,从而精确收窄Sprite类型:
修改后的代码
type Register = { animations?: string; points?: string; } type OrString<T, K extends keyof T> = T[K] extends string ? T[K] : string; type RegisterAnimation<T extends Register> = OrString<T, 'animations'>; type RegisterPoint<T extends Register> = OrString<T, 'points'>; // 定义所有可能的Register类型联合 type A = { animations: 'walk' | 'static'; points: 'topLeft' | 'topRight' }; type B = { animations: 'run' | 'jump'; points: 'bottom' }; type AllRegisters = A | B; export interface Sprite<R extends Register> { setAnimation(animation: RegisterAnimation<R>): void; getPoint(point: RegisterPoint<R>): [number, number]; // 重构类型守卫:根据传入的动画值,匹配对应的Register类型 hasAnimation<U extends RegisterAnimation<AllRegisters>>( animation: U ): this is Sprite<Extract<AllRegisters, { animations: U }>>; } type PossibleSprites = Sprite<A> | Sprite<B>; // 测试函数恢复正常类型收窄 function test(sprite: PossibleSprites): void { if (sprite.hasAnimation('run')) { // sprite 正确收窄为 Sprite<B> sprite.setAnimation('run'); sprite.getPoint('bottom'); // 报错:"walk" 不能赋值给 "run" | "jump" sprite.setAnimation('walk'); } else { // sprite 正确收窄为 Sprite<A> sprite.setAnimation('walk'); sprite.getPoint('topLeft'); // 报错:"run" 不能赋值给 "walk" | "static" sprite.setAnimation('run'); } }
关键改动说明
- 定义
AllRegisters联合类型:将所有可能的Register类型集中起来,作为类型推断的基准。 - 重构
hasAnimation的类型守卫:使用Extract<AllRegisters, { animations: U }>从联合类型中提取出animations包含U的具体Register类型,然后返回this is Sprite<该类型>,让编译器能精确匹配到联合成员。
总结
这不是TypeScript编译器的限制,而是原类型守卫的设计没有提供足够的类型关联信息。通过明确绑定所有可能的Register类型,并使用Extract完成类型匹配,可以实现基于animations的精确类型收窄,即使所有联合成员都包含points属性也能正常工作。
内容的提问来源于stack exchange,提问作者hedgehog1029

