使用TypeScript的交叉类型

16

我有两种联合类型

type TypeA = string | number;
type TypeB = string | boolean;

我使用以上内容创建了一个交叉类型type:

type Combined = TypeA & TypeB;

自然地,Combined 类型仅为 string 类型,因为它是唯一在 TypeATypeB 之间相交的类型。

但是,如果我改变 TypeB 的联合并添加一个 Date 类型,则会出现意外行为,例如:

type TypeA = string | number;
type TypeB = string | boolean | Date;
创建一个新的交叉类型:
type Combined = TypeA & TypeB;

如果我检查类型的签名,它看起来像这样:

type Combined = string | (string & Date) | (number & Date);

我的问题是为什么会发生这种情况?这是预期的吗?我认为它应该是字符串类型,因为它是唯一与交集类型


1
这可能是TypeScript中的一个bug,但是string & numbernever,而string & Datestring & Date,但我不知道你可以为string & Date分配什么值。 - Explosion Pills
@ExplosionPills 这是一个bug吗?在我看来(尽管可能很奇怪),实际上交集并不为空。例如,考虑表达式Object.assign(0, new Date())的结果... - CRice
@CRice 不太确定,但那似乎只是0。你也可以通过 Object.assign(0, '') 得到这个结果。 - Explosion Pills
1
是的,我认为它只是 0Object.assign 只是欺骗类型系统,因为它的签名被设置为返回其参数类型的交集。实际产生的值不能用作 Date 对象,因为它的类型会提示。不过,我仍然并不完全相信结果应该是 never,我认为通过一些原型操作可以得到既可用作数字又可用作日期的值。 - CRice
1
我只是说TypeScript将string&number的类型视为never:https://www.typescriptlang.org/play?#code/C4TwDgpgBAdlC8UDOwBOBLGBzKAyWArgLYBGEqA3EA - Explosion Pills
3个回答

23

这不是一个错误;它是为了允许一个名为品牌标记原语的功能(这一功能并没有被广泛宣传)。


在运行时,几乎不可能有一个值v同时是一个string(原始类型,其中typeof v === "string",而不是一个String包装对象)和一个Date。从某种意义上说,类型string & Date实际上与never相同,因为您可以将TypeScript类型视为所有适当的JavaScript值的集合。如果没有string & Date值,并且没有never值,则这些类型在逻辑上是等效的。
因此,如果编译器急切地将交集(如string & Date)缩减为never,那么这是合理的。它已经对不兼容的单元类型的交集(例如"a" & 0)和不兼容的原始类型的交集(例如string & number)进行了这样的操作,如microsoft/TypeScript#31838中实现的那样。那么为什么我们与原始类型交叉的对象类型不会发生这种情况呢?
目的是为了允许一种称为“品牌原语”的特性,在TypeScript中模拟名义类型的基元(在此FAQ条目中提到)。
TypeScript主要具有结构类型类型别名;如果两个类型具有相同的结构但名称不同,则它们是相同的类型。如果通过类型别名给现有类型命名,则它们是相同的类型。通常这正是你想要的,但有时你希望创建两种在运行时相同但需要在代码中区分的类型,因为你不希望开发人员将它们错误混合使用。
例如(这可能是一个愚蠢的例子):
type Username = string;
type Password = string;
declare function login(username: Username; password: Password): void;

在这里,我们希望确保编写调用login的TypeScript代码的人不会意外地将密码放入用户名中,反之亦然。如果上述类型别名能够防止您这样做,那就太好了:

declare function getUsername(): Username;
declare function getPassword(): Password;
login(getPassword(), getUsername()); // no error, OOPS

但实际上并没有。用户名(Username)和密码(Password)类型都只是string。使用不同的名称并不会改变这个事实。因此,有时TypeScript开发人员希望他们的类型是名义上的,以捕获像上面那样的错误。
microsoft/TypeScript#202中有一个非常长的讨论,关于如何获得名义上的类型。一种对原始类型进行操作的方法是使用“品牌”,即添加一个“虚拟”的区分属性,该属性仅存在于类型系统中,而不是运行时。因此,您可以将上述更改为:
type Username = string & { __brand: "Username" };
type Password = string & { __brand: "Password" };

突然间,你会在这里得到所需的错误提示:

login(getPassword(), getUsername()); // error! Password not assignable to Username

当然,实际上说服编译器一个特定的string确实是一个UsernamePassword,需要通过一些类似于类型断言的方法进行欺骗

function toUsername(x: string): Username {
    return x as Username; // <-- lying
}

当然,在运行时你不能真正拥有UsernamePassword类型的值,因为如果你使用一个原始的string它就不会有__brand属性。如果编译器决定急切地将这些在运行时不可能存在的品牌类型降级为never,它们将完全失效。这甚至比仅仅使用原始类型更糟糕,因为没有任何东西可以赋值给它们,但它们仍然是无法区分的并且容易引起混淆:

login(getPassword(), getUsername()); // no error again

虽然这个特性可能不是很令人愉快,但它已经被应用于现有的 TypeScript 代码中,包括 TypeScript 编译器自身的 TypeScript 源代码。将品牌化的原语减少到 never 将会对太多人造成影响,因此不值得这样做。

游乐场链接到代码


@jpaugh 请注意,此编辑不准确。 单位类型仅包含一个值。 因此,像"a"这样的字符串文字类型和像0这样的数字文字类型都是单位类型(以及undefinednull)。 类型stringnumber不是单位类型,因为"a""b"都是string类型的有效值。 stringnumber原始类型 - jcalz
1
我明白了。感谢您抽出时间来解释。我会添加一个小编辑,以帮助我更好地理解它。 - jpaugh

6
这是预期的行为,原语的交集被简化为不可,而原语与对象类型的交集不被简化(以启用诸如品牌基元类型之类的东西)。 Date不是原语,它是在lib.d.ts中定义的对象类型。
鉴于此事实,以及typescript通过将交集移至内部来规范化联合和交集,我们得到了:
type TypeA = string | number;
type TypeB = string | boolean;

type Combined = TypeA & TypeB;
 => (string | number) & (string | boolean)
 // Distributivity kicks in 
 => (string & string) | (string & boolean) | (number & string) | (number & boolean)
 //  intersection simplification
 => string | never | never | never 
 // never melts away in a union
 => string 


在第二种情况下,我们得到
type TypeA = string | number;
type TypeB = string | boolean | Date;

type Combined = TypeA & TypeB;
 => (string | number) & (string | boolean | Date)
 // Distributivity kicks in 
 => (string & string) | (string & boolean) | (string & Date) | (number & string) | (number & boolean) | (number & Date)
 //  intersection simplification, but nothing is done about Date and any primitive
 => string | never | (string & Date) never | never | (number & Date)
 // never melts away in a union
 => string 


如果你想从TypeB中提取任何TypeA类型,最好使用Extract 条件类型。
type TypeA = string | number;
type TypeB = string | boolean | Date;

type Combined = Extract<TypeA, TypeB>;

游乐场链接


2
这是因为Date不是原始类型。不兼容的原始类型会被归为never
type TypeA = string | number;
type TypeB = string | boolean;

type Combined = TypeA & TypeB;
// string | (string & number) | (string & boolean)
// => string | never | never
// => string

没有这种缩减,开发人员更容易进行调试。 您可以尝试使用自己的类,它将具有相同的行为。

游乐场

我寻找了一个更详细的答案,并找到了这个:https://stackoverflow.com/a/53545038/14438744


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