通用类型的区分联合

30

我希望能够在泛型中使用联合判别。然而,它似乎并没有起作用:

示例代码(在 TypeScript Playground 上查看):

interface Foo{
    type: 'foo';
    fooProp: string
}

interface Bar{
    type: 'bar'
    barProp: number
}

interface GenericThing<T> {
    item: T;
}


let func = (genericThing: GenericThing<Foo | Bar>) => {
    if (genericThing.item.type === 'foo') {

        genericThing.item.fooProp; // this works, but type of genericThing is still GenericThing<Foo | Bar>

        let fooThing = genericThing;
        fooThing.item.fooProp; //error!
    }
}

我希望 TypeScript 能够认识到,由于我在泛型的 item 属性上进行了区分,因此 genericThing 必须是 GenericThing<Foo>。我猜这可能不受支持?
此外,有点奇怪的是,在直接赋值之后,fooThing.item 失去了它的区分。

在最后一行出现了什么错误?从genericThing中仅提取项目,无论是在函数顶部还是通过在参数中进行解构,是否有任何区别? - jonrsharpe
@jonrsharpe 打开 TypeScript Playground 链接,你就可以看到了。fooProp 在类型上不存在... - NSjonas
3个回答

32

问题

在区分联合类型中进行类型缩小受到几个限制:

无法解包泛型

首先,如果类型是泛型的,泛型将不会被解包以缩小类型:缩小需要使用联合类型。所以,例如下面的代码是无法工作的:

let func = (genericThing:  GenericThing<'foo' | 'bar'>) => {
    switch (genericThing.item) {
        case 'foo':
            genericThing; // still GenericThing<'foo' | 'bar'>
            break;
        case 'bar':
            genericThing; // still GenericThing<'foo' | 'bar'>
            break;
    }
}

虽然这样做:

let func = (genericThing: GenericThing<'foo'> | GenericThing<'bar'>) => {
    switch (genericThing.item) {
        case 'foo':
            genericThing; // now GenericThing<'foo'> !
            break;
        case 'bar':
            genericThing; // now  GenericThing<'bar'> !
            break;
    }
}

我怀疑展开具有联合类型参数的通用类型会引起各种奇怪的边角案例,编译器团队无法以令人满意的方式解决这些问题。 不会通过嵌套属性进行缩小 即使我们有多个类型的联合体,如果我们在嵌套属性上进行测试,也不会进行缩小。字段类型可能会根据测试而缩小,但根对象不会被缩小:
let func = (genericThing: GenericThing<{ type: 'foo' }> | GenericThing<{ type: 'bar' }>) => {
    switch (genericThing.item.type) {
        case 'foo':
            genericThing; // still GenericThing<{ type: 'foo' }> | GenericThing<{ type: 'bar' }>)
            genericThing.item // but this is { type: 'foo' } !
            break;
        case 'bar':
            genericThing;  // still GenericThing<{ type: 'foo' }> | GenericThing<{ type: 'bar' }>)
            genericThing.item // but this is { type: 'bar' } !
            break;
    }
}

解决方案

解决方案是使用自定义类型保护。我们可以创建一个相当通用的类型保护版本,适用于任何具有type字段的类型参数。不幸的是,我们不能为任何泛型类型创建它,因为它将与GenericThing绑定:

function isOfType<T extends { type: any }, TValue extends string>(
  genericThing: GenericThing<T>,
  type: TValue
): genericThing is GenericThing<Extract<T, { type: TValue }>> {
  return genericThing.item.type === type;
}

let func = (genericThing: GenericThing<Foo | Bar>) => {
  if (isOfType(genericThing, "foo")) {
    genericThing.item.fooProp;

    let fooThing = genericThing;
    fooThing.item.fooProp;
  }
};

哦,天啊...那个解决方案真的很粗糙!但好在知道它是可行的。我的解决方案就是针对嵌套对象编码,而不是试图接受狭窄的泛型。例如:如果我需要进行区分处理,那么让我的函数接受Foo(而不是直接接受GenericThing<Foo>)。 - NSjonas
@NSjonas 那也是一个选项 :-) - Titian Cernicova-Dragomir

7

正如@Titian所解释的,当你真正需要的是以下内容时,问题就会出现:

GenericThing<'foo'> | GenericThing<'bar'>

但是你有一个被定义为:

GenericThing<'foo' | 'bar'>

显然,如果您只有这样两个选择,您可以自己扩展它,但这显然不具有可伸缩性。

假设我有一个包含节点的递归树。这是一个简化:

// different types of nodes
export type NodeType = 'section' | 'page' | 'text' | 'image' | ....;

// node with children
export type OutlineNode<T extends NodeType> = AllowedOutlineNodeTypes> = 
{
    type: T,
    data: NodeDataType[T],   // definition not shown

    children: OutlineNode<NodeType>[]
}
OutlineNode<...>所代表的类型需要是区分联合类型,由于有type: T属性,因此它们是区分联合类型。
假设我们有一个节点实例并遍历其子节点:
const node: OutlineNode<'page'> = ....;
node.children.forEach(child => 
{
   // check a property that is unique for each possible child type
   if (child.type == 'section') 
   {
       // we want child.data to be NodeDataType['section']
       // it isn't!
   }
})

显然,在这种情况下,我不希望使用所有可能的节点类型来定义子节点。

另一种选择是在定义子节点时“展开”NodeType。不幸的是,我找不到一种通用的方法,因为我不能提取出类型名称。

相反,您可以执行以下操作:

// create type to take 'section' | 'image' and return OutlineNode<'section'> | OutlineNode<'image'>
type ConvertToUnion<T> = T[keyof T];
type OutlineNodeTypeUnion<T extends NodeType> = ConvertToUnion<{ [key in T]: OutlineNode<key> }>;

然后children的定义发生变化,变成:

 children: OutlineNodeTypeUnion<NodeType>[]

现在,当您遍历子元素时,它是所有可能性的扩展定义,并且区分联合类型保护自动启动。 为什么不只使用类型保护呢?1)您真的不需要。 2)如果使用类似于Angular模板的东西,您不希望对类型保护进行无数次调用。事实上,这种方法确实允许在 *ngIf 中进行自动类型缩小,但不幸的是,不能在 ngSwitch 中使用。

定义一种联合类型正是我一直在寻找的。谢谢! - Draculater

0

很好的一点是,在if块内,表达式genericThing.item被视为Foo。我曾以为只有在将其提取到变量(const item = genericThing.item)之后才能起作用。最新版本的TS可能会有更好的行为。

这使得像官方文档中辨别联合中的area函数那样的模式匹配成为可能,而这在C#中实际上是缺失的(在v7中,switch语句中仍然需要一个default情况)。

事实上,奇怪的是,即使在if块内itemFoogenericThing仍然被视为未区分(作为GenericThing<Foo | Bar>而不是GenericThing<Foo>)。因此,fooThing.item.fooProp;的错误并不让我感到惊讶。

我猜 TypeScript 团队仍需要做一些改进来支持这种情况。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接