比较来自同一可区分联合的对象的最短类型安全方法是什么?

What is the shortest type-safe way to compare objects from the same discriminated union?

让我们假设存在以下可区分联合类型:

interface Circle {
  type: 'circle';
  radius: number;
}

interface Square {
  type: 'square';
  sideLength: number;
}

type Shape = Circle | Square;

我正在尝试找到实现类型安全比较函数的最简单方法(即没有任何转换或潜在的运行时错误),它检查此联合类型的 2 个对象是否等价。

我问的原因是因为当您验证对象的判别式 属性 相同时,TS 检查器似乎不会将对象的类型缩小到相同的具体类型,即:

function areEqual(shapeA: Shape, shapeB: Shape): boolean {
  if (shapeA.type !== shapeB.type) {
    return false;
  }
   
  switch (shapeA.type) {
    case ('circle'):
      return shapeA.radius === shapeB.radius; // <- TS complains that shapeB might NOT have 'radius' property here, even though the if above guarantees that shapeA and shapeB's types are the same
    case ('square'):
    ....
  }

有什么办法可以避免这个错误吗?

注意:我知道我可以通过使用另一个内部 switch 检查 shapeB 的类型来保持类型安全,但这将需要大量不必要的代码来安抚类型检查器,特别是如果联合有超过2种。

TypeScript 的control flow analysis is what allows the compiler to narrow 基于变量和属性的类型检查。但这种分析是根据一组仅在某些特定情况下触发的启发式规则构建的。

它不会执行完整的“what-if”分析,据此,union type 的每个表达式假设都缩小到每个可能的联合成员。比如在areEqual()的body里面,编译器不会考虑下面所有的情况

  • 如果 shapeACircle 并且 shapeBCircle,那么类型应该是什么?
  • 如果 shapeACircle 并且 shapeBSquare,那么类型应该是什么?
  • 如果 shapeASquare 并且 shapeBCircle,那么类型应该是什么?
  • 如果 shapeASquare 并且 shapeBSquare,那么类型应该是什么?

如果它这样做了,编译器肯定能够看到您的实现是安全的。但如果它做这样的事情,那么大多数 non-trivial 程序的编译时间可能比您愿意等待的时间更长(委婉地说)。只是没有足够的资源来进行强力分析。有一次我希望有某种方法可以在有限的情况下有选择地选择进行此类分析(请参阅 microsoft/TypeScript#25051),但该语言中不存在此类功能。所以暴力分析已经结束了。

编译器没有人类智能(无论如何从 TS4.6 开始),因此它无法弄清楚如何将其分析抽象为更高阶。作为一个人,我可以理解,一旦我们建立 (shapeA.type === shapeB.type),它就会“联系在一起”shapeAshapeB,这样任何一个变量的后续检查 type 属性 应该缩小两个变量。但是编译器不理解这个。

它只有一组针对特定情况的启发式方法。对于 discriminated unions, if you want narrowing, you need to check the discriminant property against particular literal type 个常量。

没有 built-in 支持您的 areEqual() 场景,很可能是因为它不够好,不值得硬编码。


那你能做什么?好吧,TypeScript 确实使您能够编写自己的 user-defined type guard functions,这使您可以更精细地控制缩小的发生方式。但是使用它需要对代码进行一些重要的重构。例如:

function areEqual(...shapes: [Shape, Shape]): boolean {

  if (!hasSameType(shapes)) return false;

  if (hasType(shapes, "circle")) {
    return shapes[0].radius === shapes[1].radius;
  } else if (hasType(shapes, "square")) {
    return shapes[0].sideLength === shapes[1].sideLength;
  }

  assertNever(shapes); 
}

这里我们将 shapeAshapeB 参数打包到 [Shape, Shape] tuple type 的单个 shapes 剩余参数中。我们需要这样做,因为 user-defined 类型保护函数只作用于单个参数,所以如果我们希望同时缩小两个对象,它会迫使我们在发生这种情况的地方创建一个值。

type SameShapeTuple<T extends Shape[], U extends Shape = Shape> =
  Extract<U extends Shape ? { [K in keyof T]: U } : never, T>;

function hasSameType<T extends Shape[]>(shapes: T): shapes is SameShapeTuple<T> {
  return shapes.every(s => s.type === shapes[0].type);
}

SameShapeTuple<T> 是一种辅助类型,它采用 Shape array/tuple 类型并将 Shape 联合分布在数组类型中。所以 SameShapeTuple<Shape[]>Circle[] | Square[]SameShapeTuple<[Shape, Shape, Shape]>[Circle, Circle, Circle] | [Square, Square, Square]hasSameType() 接受类型为 T 和 returns shapes is SameShapeTuple<T> 的数组 shapes。在 areEqual() 中,我们使用 hasSameType()[Shape, Shape] 缩小为 [Circle, Circle] | [Square, Square]

function hasType<T extends SameShapeTuple<Shape[]>, K extends T[number]['type']>(
  shapes: T, type: K
): shapes is Extract<T, { type: K }[]> {
  return shapes[0]?.type === type;
}

hasType(shapes, type) 函数是一个类型保护,它将 union-typed shapes 数组缩小到联合体中具有 type 属性 元素的任何成员匹配 type。在 areEqual() 中,我们使用 hasType()[Circle, Circle] | [Square, Square] 缩小为 [Circle, Circle][Square, Square] 甚至 never,具体取决于 type 参数传递给它。

function assertNever(x: never): never {
  throw new Error("Expected unreachable, but got a value: " + String(x));
}

最后,因为你需要使用 if/else 块而不是 user-defined 类型保护函数的 switch 语句,所以我们有 assertNever() ,它作为详尽检查以确保编译器同意它实际上不可能从函数的末尾脱落(有关更多信息,请参阅 microsoft/TypeScript#21985)。

所有这些都没有错误。重构的复杂性是否值得取决于您。


请注意,您不必使这些 Shape 特定。您可以抽象类型保护函数,这样您也可以传入判别键的名称,它适用于任何判别联合。它可能看起来像这样:

type SameDiscUnionMemberTuple<T extends any[], U extends T[number] = T[number]> =
  Extract<U extends unknown ? { [K in keyof T]: U } : never, T>;

function hasSameType<T extends object[], K extends keyof T[number]>(shapes: T, typeProp: K):
  shapes is SameDiscUnionMemberTuple<T> {
  const sh: T[number][] = shapes;
  return sh.every(s => s[typeProp] === sh[0][typeProp]);
}

function hasType<T extends object[], K extends keyof T[number], V extends (string | number) & T[number][K]>(
  shapes: T, typeProp: K, typeVal: V): shapes is Extract<T, Record<K, V>[]> {
  const sh: T[number][] = shapes;
  return sh[0][typeProp] === typeVal;
}
function areEqual(...shapes: [Shape, Shape]): boolean {

  if (!hasSameType(shapes, "type")) return false;

  if (hasType(shapes, "type", "circle")) {
    return shapes[0].radius === shapes[1].radius;
  } else if (hasType(shapes, "type", "square")) {
    return shapes[0].sideLength === shapes[1].sideLength;
  }

  assertNever(shapes);
}

我不打算详细讨论这个问题,因为这个答案已经足够长了。

Playground link to code