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

发布于 2025-01-12 17:04:10 字数 891 浏览 0 评论 0 原文

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

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种。

Let's assume the existence of the following discriminated union type:

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

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

type Shape = Circle | Square;

I am trying to find the easiest way to implement a type-safe comparison function (i.e. without any casts or potential runtime errors), which checks if 2 objects of this union type are equivalent.

The reason why I ask is because the TS checker does NOT seem narrow the objects' types to the same concrete type when you verify that their discriminant property is the same, i.e.:

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'):
    ....
  }

Is there any way to avoid this error?

NOTE: I understand that I can preserve type-safety by checking shapeB's type with yet another inner switch but this would require a lot of unnecessary code just to appease the type checker, especially if the union has more than 2 types.

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(1

扛刀软妹 2025-01-19 17:04:10

TypeScript 的控制流分析允许编译器根据检查缩小变量和属性的类型。但这种分析是根据一组启发式规则构建的,这些规则仅在某些特定情况下触发。

它不会执行完整的“假设”分析,即 联盟类型假设缩小到每个可能的联盟成员。例如,在 areEqual() 函数体内,编译器不会考虑以下所有情况:

  • 如果 shapeA 是一个 Circle,类型应该是什么code> 和 shapeB 是一个 Circle
  • 如果 shapeACircle 并且 shapeBSquare,类型应该是什么?
  • 如果 shapeASquare 并且 shapeBCircle,类型应该是什么?
  • 如果 shapeASquare 并且 shapeBSquare,类型应该是什么?

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

编译器不具备人类智能(至少从 TS4.6 开始),因此它无法弄清楚如何将其分析抽象到更高的顺序。作为一个人,我可以理解,一旦我们建立了 (shapeA.type === shapeB.type),它就会将 shapeAshapeB<“联系在一起” /code> 这样,对任一变量的 type 属性的任何后续检查都应缩小这两个变量的范围。但编译器不理解这一点。

它只有一组针对特定情况的启发式方法。对于受歧视的工会,如果你想缩小范围,你需要针对特定​​ 文字类型常量。

对于您的 areEqual() 场景没有内置支持,很可能是因为它没有足够的值值得进行硬编码。


那么你能做什么呢?好吧,TypeScript 确实让您能够编写自己的 用户定义的类型保护函数,它允许您对缩小的发生方式进行更细粒度的控制。但使用它需要对代码进行一些重要的重构。例如:

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]< 的单个 shapes 剩余参数中/code> 元组类型。我们需要这样做,因为用户定义的类型保护函数仅作用于单个参数,因此如果我们希望同时缩小两个对象,它会迫使我们在发生这种情况时创建一个值。

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 是一种辅助类型,它采用 Shape 数组/元组类型并将 Shape 联合分布在数组类型中。所以 SameShapeTupleCircle[] | Square[]SameShapeTuple<[Shape, Shape, Shape]>[Circle, Circle, Circle] | [正方形,正方形,正方形]hasSameType() 接受 T 类型的 shapes 数组并返回 shapes is SameShapeTuple。在 areEqual() 内部,我们使用 hasSameType()[Shape, Shape] 缩小为 [Circle, Circle] | [正方形,正方形]

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) 函数是一个类型保护,它将联合类型的 shapes 数组缩小为联合中具有 type 元素的成员code> 属性与 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 块而不是 switch 语句,因此我们有 assertNever(),它充当详尽的检查,以确保编译器同意它实际上不可能脱离函数的末尾(请参阅microsoft/TypeScript#21985 了解更多信息)。

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


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

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

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

<一href="https://www.typescriptlang.org/play?#code/HYQwtgpgzgDiDGEAEApeIA2AvJBvAsAFBFJICWwALhAE4BmCyAwmTfBsgY aaZQJ4wIALiQByeK3YRRAbhI8aIACZkArlBHBVYAEa053JAF8i8itXqMkAZQCOqkDU7zeA4WKj3H0gzyRQyJQgAGQhgAHNKAAtNbT0aX2NT QwB6FKQoykoYDTTwsmjVHQA6eAB7MBSwMngaMqgyukoUgBU3a1qyGGayKChVaBSAJgBGAE4ADgBWeTpVYHhKMjLgJBA+2koAOQgAN1oACg APTT3aAEpT-Zo8FyRouoB3JGAIZ4BRGjqaA4Aid6OgkWECUSHmTgQURAOg4ABokDpVJQkOEysiQEhdpgBiJfkgANQ2Sg0CjhY7nc6JEzEQz 8QQ2KH0gC8SBYbA4SAAPjYvE4DPJQJBYFZrIyINYgWQ6DVbjS-HTkNZwOKxS1VDAOAAeFpICBHajAJRQBkgQQAbQAuvCAKq6-VhI0m5lOi AAPiQTLupABxIQlE1tr1BsdotNyAA-HgkGaANLkVYAawgfEasBaFpEtqMSCutHhLVd-MMpDmCyWKwy6yVkDagm1duDxtD5otroOUDFGjTl3 8nfiteVzYgao1EG17q4flITkoqhoqw7YagxTOND47Y97uXCo9TJZi8EUDNAAYLCUFZS7tS7qXFstVlCoLWxzqgw6B5AhyOtUPLa74XGb6G saLRmloui0BaZqiAqoitgcXq9kuIgtPCCoiDGdw9ge0D9kgPqKIs2rwrg9xuBhxh-rKU5IDOc4Lp2J4WuG55uLuLIKokpDXnKPC3uWqzeO8 XgYAcxTiThXZmkO8JDhaPY6GUZQcCAqxcIhUpIACACEj7VsObjtp2FK0RAs7zkgDAYFAEBFjRmkHI+z5GUu8K-BI7IQL8JmTjRpnmQxS5M cUIgqOo7FIYeZojGeoVqFAXE8NmEDWcgDlOYZkluZ4DhON55zUX5dEWZJwUBEEoQRNEEWlTFxTlSEYSRFEiXcckNHrDZNDbKulmHpexZJDS 8jUqQgrQHAiBIAAAsmUTgaOJhLQmCyu1PA7vpAAivTwNawD3tNEAQTQ34vg275rMAfCWja53AWmYFxJBHoPeB8Sth6iEEX6AZ3Y68wJsAZS PKskakbG8ZIEmKZ0GmGZIFmOYvKu+aFmtJbzHeFZ6cqz71kBjplDoABWECLDdSCAfa93Q6moFvZBbaSShaFuAACnUMAYZciGSXhW07XtB1H fEp3joVfjlMAUDIh2KGPcdUEWi9kmtf59G9iu1zrsaTJbmaCoc2UMDK3u+5RExBvs5z8mJTxfj8felZPm4+PU4TJNk5QFNU42UPJnTCvvf CABqf3Gu2xKklyLxPTQBUAGSvXHUExvBvOdizZGCEbXOU6zggh5gIgh9hfa9Ph+qEf6qFIAASmTc1KJqMah66VG+ZLKwy728sMzQSsq52av FQxluGzbEUKkXGB2zemMCWsTjCQ4onifVmfRjJLrySIinKRAqmrYNpAObpVa45lnZuQq+XqxZVk2XZU7peszlZUgvy325HlSHfndTlHpFa AwU4rhTNsAo8dUwEJUQslVK5BYaOTflfVyn9v6fxyt4f+iFpxmQ1qVU89VAiNSqlEGqjE6oNUqs1NW9spydU2Dsa4fVoADT8DxNaPEjBAA" rel="nofollow noreferrer">Playground 代码链接

TypeScript's control flow analysis is what allows the compiler to narrow the type of variables and properties based on checks. But this analysis is built from a set of heuristic rules that only trigger in certain specific situations.

It does not perform a full "what-if" analysis whereby every expression of a union type is hypothetically narrowed to every possible union member. For example, inside the body of areEqual(), the compiler does not consider all of the following situations

  • What should the types be if shapeA is a Circle and shapeB is a Circle?
  • What should the types be if shapeA is a Circle and shapeB is a Square?
  • What should the types be if shapeA is a Square and shapeB is a Circle?
  • What should the types be if shapeA is a Square and shapeB is a Square?

If it did this, the compiler would surely be able to see that your implementation is safe. But if it did things like this, then most non-trivial programs would probably take longer to compile than you'd be willing to wait (to put it mildly). There just aren't enough resources to do a brute force analysis. At one point I wished for some way to selectively opt into such analysis in limited situations (see microsoft/TypeScript#25051) but no such feature exists in the language. So brute force analysis is out.

The compiler doesn't have human intelligence (as of TS4.6 anyway) so it can't figure out how to abstract its analysis to a higher order. As a human being, I can understand that once we establish (shapeA.type === shapeB.type), it "ties together" shapeA and shapeB such that any subsequent check of either variable's type property should narrow both variables. But the compiler does not understand this.

It only has a set of heuristics for specific situations. For discriminated unions, if you want narrowing, you need to check the discriminant property against particular literal type constants.

There is no built-in support for your areEqual() scenario, most likely because it doesn't come up enough to be worth hardcoding.


So what can you do? Well, TypeScript does give you the ability to write your own user-defined type guard functions which allow you some more fine grained control over how narrowing occurs. But using it requires some nontrivial refactoring of your code. For example:

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

Here we are packaging the shapeA and shapeB parameters into a single shapes rest argument of the [Shape, Shape] tuple type. We need to do that because user-defined type guard functions only act on a single argument, so if we want both objects to be narrowed at once, it forces us to create a single value where that happens.

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> is a helper type that takes a Shape array/tuple type and distributes the Shape union across the array type. So SameShapeTuple<Shape[]> is Circle[] | Square[] and SameShapeTuple<[Shape, Shape, Shape]> is [Circle, Circle, Circle] | [Square, Square, Square]. And hasSameType() takes an array of shapes of type T and returns shapes is SameShapeTuple<T>. Inside areEqual(), we are using hasSameType() to narrow [Shape, Shape] to [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;
}

The hasType(shapes, type) function is a type guard that will narrow a union-typed shapes array to whichever member of the union has elements whose type property matches type. Inside areEqual(), we are using hasType() to narrow [Circle, Circle] | [Square, Square] to either [Circle, Circle] or [Square, Square] or even never depending the type parameter passed to it.

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

And finally, because you need to use if/else blocks instead of switch statements for user-defined type guard functions, we have assertNever(), which acts as an exhaustiveness check to make sure that the compiler agrees that it's not really possible to fall off the end of the function (see microsoft/TypeScript#21985 for more info).

All of this works with no error. Whether or not it's worth the complexity of refactoring is up to you.


Note that you don't have to make these Shape-specific. You could abstract the type guard functions so that you also pass in the name of the discriminant key and it will work for any discriminated union. It could look like this:

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

I'm not going to go over that in detail because this answer is long enough as it is.

Playground link to code

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文