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

如何通过部分检查在TypeScript中收窄复杂联合类型?

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

关键改动说明

  1. 定义AllRegisters联合类型:将所有可能的Register类型集中起来,作为类型推断的基准。
  2. 重构hasAnimation的类型守卫:使用Extract<AllRegisters, { animations: U }>从联合类型中提取出animations包含U的具体Register类型,然后返回this is Sprite<该类型>,让编译器能精确匹配到联合成员。

总结

这不是TypeScript编译器的限制,而是原类型守卫的设计没有提供足够的类型关联信息。通过明确绑定所有可能的Register类型,并使用Extract完成类型匹配,可以实现基于animations的精确类型收窄,即使所有联合成员都包含points属性也能正常工作。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.21 07:44:53