通用类型的歧视联盟

Discriminated Union of Generic type

我希望能够将 union discrimination 与泛型一起使用。但是,它似乎不起作用:

示例代码 (view on 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!
    }
}

我希望打字稿能够识别,因为我区分了通用 item 属性,所以 genericThing 必须是 GenericThing<Foo>

我猜这只是不受支持?

此外,有点奇怪的是,在直接赋值后,它 fooThing.item 失去了它的辨别力。

表达式 genericThing.itemif 块中被视为 Foo 是一个很好的观点。我认为它只有在将它提取到一个变量(const item = genericThing.item)之后才有效。可能是最新版本 TS 的更好行为。

这会启用 Discriminated Unions 官方文档中函数 area 中的模式匹配,而这在 C# 中实际上是缺失的(在 v7 中,default 案例仍然是必要的在这样的 switch 语句中)。

确实,奇怪的是 genericThing 仍然被视为未受歧视(作为 GenericThing<Foo | Bar> 而不是 GenericThing<Foo>),即使在 if 块内 item是一个Foo!然后 fooThing.item.fooProp; 的错误并不让我感到惊讶。

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

问题

受歧视联合中的类型缩小受到几个限制:

不展开泛型

首先,如果类型是泛型,泛型将不会解包以缩小类型:缩小需要联合才能工作。因此,例如这不起作用:

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

正如@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<...> 表示的类型需要是 discriminated union,这是因为 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!
   }
})

很明显,在这种情况下,我不想定义具有所有可能节点类型的子节点。

另一种方法是 'explode out' 我们定义子项的 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>[]

现在,当您遍历子项时,它是所有可能性的扩展定义,并且 descriminated 联合类型保护会自行启动。

为什么不直接使用类型保护? 1)你真的不需要。 2) 如果使用类似 angular 模板的东西,您不希望对类型保护程序进行无数次调用。这种方式实际上允许在 *ngIf 范围内自动缩小类型,但不幸的是 ngSwitch