让我们假设存在以下可区分的联合类型:
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.
发布评论
评论(1)
TypeScript 的控制流分析允许编译器根据检查缩小变量和属性的类型。但这种分析是根据一组启发式规则构建的,这些规则仅在某些特定情况下触发。
它不会执行完整的“假设”分析,即 联盟类型假设缩小到每个可能的联盟成员。例如,在
areEqual()
函数体内,编译器不会考虑以下所有情况:shapeA
是一个Circle
,类型应该是什么code> 和shapeB
是一个Circle
?shapeA
是Circle
并且shapeB
是Square
,类型应该是什么?shapeA
是Square
并且shapeB
是Circle
,类型应该是什么?shapeA
是Square
并且shapeB
是Square
,类型应该是什么?如果这样做,编译器肯定能够看到您的实现是安全的。但如果它做了这样的事情,那么大多数重要的程序可能需要比您愿意等待的时间更长的时间(温和地说)。只是没有足够的资源来进行强力分析。有一次,我希望有某种方法可以在有限的情况下有选择地选择进行此类分析(请参阅 microsoft/TypeScript #25051),但该语言中不存在这样的功能。所以暴力分析已经过时了。
编译器不具备人类智能(至少从 TS4.6 开始),因此它无法弄清楚如何将其分析抽象到更高的顺序。作为一个人,我可以理解,一旦我们建立了
(shapeA.type === shapeB.type)
,它就会将shapeA
和shapeB<“联系在一起” /code> 这样,对任一变量的
type
属性的任何后续检查都应缩小这两个变量的范围。但编译器不理解这一点。它只有一组针对特定情况的启发式方法。对于受歧视的工会,如果你想缩小范围,你需要针对特定 文字类型常量。
对于您的
areEqual()
场景没有内置支持,很可能是因为它没有足够的值值得进行硬编码。那么你能做什么呢?好吧,TypeScript 确实让您能够编写自己的 用户定义的类型保护函数,它允许您对缩小的发生方式进行更细粒度的控制。但使用它需要对代码进行一些重要的重构。例如:
这里我们将
shapeA
和shapeB
参数打包到[Shape, Shape]< 的单个
shapes
剩余参数中/code> 元组类型。我们需要这样做,因为用户定义的类型保护函数仅作用于单个参数,因此如果我们希望同时缩小两个对象,它会迫使我们在发生这种情况时创建一个值。SameShapeTuple
是一种辅助类型,它采用Shape
数组/元组类型并将Shape
联合分布在数组类型中。所以SameShapeTuple
是Circle[] | Square[]
和SameShapeTuple<[Shape, Shape, Shape]>
是[Circle, Circle, Circle] | [正方形,正方形,正方形]
。hasSameType()
接受T
类型的shapes
数组并返回shapes is SameShapeTuple
。在areEqual()
内部,我们使用hasSameType()
将[Shape, Shape]
缩小为[Circle, Circle] | [正方形,正方形]
。hasType(shapes, type)
函数是一个类型保护,它将联合类型的shapes
数组缩小为联合中具有type
元素的成员code> 属性与type
匹配。在areEqual()
内部,我们使用hasType()
来缩小[Circle, Circle] | 的范围。 [Square, Square]
为[Circle, Circle]
或[Square, Square]
甚至never
,具体取决于>type
传递给它的参数。最后,由于您需要对用户定义的类型保护函数使用
if
/else
块而不是switch
语句,因此我们有assertNever()
,它充当详尽的检查,以确保编译器同意它实际上不可能脱离函数的末尾(请参阅microsoft/TypeScript#21985 了解更多信息)。所有这些都没有错误。重构的复杂性是否值得取决于您。
请注意,您不必使这些形状特定。您可以抽象类型保护函数,以便您还传递判别键的名称,并且它将适用于任何判别联合。它可能看起来像这样:
我不会详细讨论这个问题,因为这个答案已经足够长了。
<一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 situationsshapeA
is aCircle
andshapeB
is aCircle
?shapeA
is aCircle
andshapeB
is aSquare
?shapeA
is aSquare
andshapeB
is aCircle
?shapeA
is aSquare
andshapeB
is aSquare
?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
andshapeB
such that any subsequent check of either variable'stype
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:
Here we are packaging the
shapeA
andshapeB
parameters into a singleshapes
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.SameShapeTuple<T>
is a helper type that takes aShape
array/tuple type and distributes theShape
union across the array type. SoSameShapeTuple<Shape[]>
isCircle[] | Square[]
andSameShapeTuple<[Shape, Shape, Shape]>
is[Circle, Circle, Circle] | [Square, Square, Square]
. AndhasSameType()
takes an array ofshapes
of typeT
and returnsshapes is SameShapeTuple<T>
. InsideareEqual()
, we are usinghasSameType()
to narrow[Shape, Shape]
to[Circle, Circle] | [Square, Square]
.The
hasType(shapes, type)
function is a type guard that will narrow a union-typedshapes
array to whichever member of the union has elements whosetype
property matchestype
. InsideareEqual()
, we are usinghasType()
to narrow[Circle, Circle] | [Square, Square]
to either[Circle, Circle]
or[Square, Square]
or evennever
depending thetype
parameter passed to it.And finally, because you need to use
if
/else
blocks instead ofswitch
statements for user-defined type guard functions, we haveassertNever()
, 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:I'm not going to go over that in detail because this answer is long enough as it is.
Playground link to code