介绍
集合论是数学的一个分支,专门研究集合,即元素的集合。我们可以通过枚举集合中包含的元素来定义集合:
或者通过声明一个规则来确定哪些元素属于集合:
这个符号被读作“属于集合的 S 所有元素 x 的集合,使得 x 满足 P”。这个 P 函数被称为谓词,其目的是选择哪些元素属于集合。把谓词看作是对 filter()
函数的回调:如果输入中的元素属于我们正在创建的集合,它将返回 true
。是每个元素必须满足的约束,以便成为集合中的成员。
一旦定义了集合,我们就可以描述给定元素与集合的关系。一个元素可以是,也可以不是一个集合的成员(即属于集合):
我们还可以描述一个集合与另一个集合之间的关系。当且仅当第一个集合的每个元素也是第二个集合的元素时,一个集合就是另一个集合的子集(即包含在其中)。换句话说,当一个集合中的所有元素都存在于另一个集合中时,那么第一个集合就是第二个集合的子集。此外,如果第二个集合中至少有一个元素不存在于第一个集合中,那么第一个(较小的)集合就是第二个(较大的)集合的恰当子集,而第二个集合又是第一个集合的超集。
一个集合是另一个集合的子集,但不是另一个集合的恰当子集,那么这个集合只能等于另一个集合。一个集合等于另一个集合,当且仅当,前者的每个元素都是后者的成员,而后者的每个元素也是前者的成员。集合不考虑顺序和重复——两个集合相等,当且仅当,它们包含相同的元素。
图形:
相比之下,考虑非子集。如果第一个集合中至少有一个元素不是第二个集合的成员,那么这个集合就不是另一个集合的子集——这些是重叠的集合。如果第一个集合中没有元素是第二个集合的成员,那么这些集合就是不相交的集合。
图形:
最后,两个特殊情况:
空集,符号为Ø,是没有任何内容的集合,例如,包含不相交集合之间共有元素的集合。准确地说,空集不是没有内容-而是没有内容的容器。
与之相对的是普遍集,符号为U,每个集合都是它的子集,每个元素都是它的成员。在数学中,普遍集划定了讨论的边界。
作为集合的类型
集合理论为 TypeScript 中类型的推理提供了一种思维模型,通过集合理论的视角,我们可以将类型看作一组可能的值,也就是说,类型的每个值都可以被看作是集合中的元素,这使得类型可以与集合相比较,集合的元素根据集合的定义属于该集合。
想象一下…
-
number
类型是所有可能数的无限集合, -
string
类型是所有可能的字符排列的无限集合, -
object
类型是对象可以采取的任何可能形状的无限集合 - 在 JavaScript 中,对象包括函数、数组、日期、正则表达式等。
不是所有的集合类型都是无限的,比如 undefined
、 null
和 boolean
类型,它们都是包含有限数量元素的集合。
想象一下…
-
undefined
类型为包含单值undefined
的有限集, -
null
类型为包含单值null
的有限集, -
boolean
类型是包含两个值true
和false
的有限集。
其他有限集合是字符串字面量类型和字符串字面量联合类型 - 第一个集合包含一个用户指定的字符串字面量;第二个集合包含少量用户指定的字符串字面量。这些集合中的每一个都是所有可能字符串集合的适当子集:
// both true, string literal ⊂ string
type W = 'a' extends string ? true : false;
type X = 'a' | 'b' extends string ? true : false;
// true, string literal ⊆ same string literal
type Y = 'a' extends 'a' ? true : false;
// true, string ⊆ string
type Z = string extends string ? true : false;
注意条件类型中的 extends
在 TypeScript 中是如何等效的:
-
(固有子集,即A的每个元素都在B中,并且B有额外的元素)。
-
(子集,即 A 的每个元素都在 B 中,并且 B 没有额外的元素)
这对于一般约束中的 extends
也是成立的:
// constraint: T ⊂ string or T ⊆ string
declare function myFunc<T extends string>(arg: T): T[];
在接口声明中:
interface Person {
name: string;
}
interface Employee extends Person {
salary: number;
}
// true, Person ⊂ object
type Q = Person extends object ? true : false;
// true, Employee ⊂ Person
type R = Employee extends Person ? true : false;
由于 object
是所有可能对象形状的集合,而接口是所有可能对象形状的集合,这些对象形状的属性与接口匹配,因此任何给定的接口都是 object
类型的适当子集。
反过来,当一个子接口 extends
一个父接口时,子接口就是所有可能的对象形状的集合,这些对象形状的属性与父接口匹配。因此,子接口是父接口的一个固有子集,父接口本身是 object
类型的一个固有子集。
还要注意,如果一个类型是另一个类型的恰当子集,那么它们之间的关系暗示了它们在赋值时的兼容性:
let myString: string = 'myString';
let myStringLiteral: 'only' | 'specific' | 'strings' = 'only';
// both assignable, string ⊂ string, string literal ⊂ string
myString = 'myNewString';
myString = myStringLiteral;
// not assignable, string ⊄ string literal
myStringLiteral = myString;
这些和其他编译器行为都由集合理论解释。
把类型看作集合可以帮助我们推理:
-
赋值时的类型兼容性。
-
使用类型操作符创建类型。
-
条件类型解析。
第 1 部分:可赋值性
赋值操作将值存储在标记为变量的特定内存位置中,值和变量都是类型化的,因此赋值能力(即赋值兼容性)取决于两个类型:被赋值的值和接收变量的类型。
当两个类型相同时,赋值成功:
let a: number;
a = 123; // succeeds, number is assignable to number
但是当两个类型不相同时,为了赋值成功,必须进行类型转换,当我们把一个类型的值赋给一个不同类型的变量时,我们进行类型转换,也就是说,我们使值的类型变成变量中的另一种类型。
类型转换通常采用上转换的形式:我们将值的类型扩展为变量中更具包容性的类型,例如字符串字面值变成字符串。
let myString: string = 'myString';
let myStringLiteral: 'only' | 'specific' | 'strings' = 'only';
myString = myStringLiteral; // upcasting succeeds, assignable
Upcasting 将子类型转换为超类型,也就是将合适的子集转换为超集,TypeScript 允许这种转换,因为它是类型安全的:如果一个集合是另一个集合的合适子集,那么小集合中的任何元素都是大集合中的成员。
另一方面,向下类型转换通常是不允许的。为了确保类型安全,我们不能声明一个大集合中的成员也是一个小集合中的成员-我们不能确定这一点。如果两个集合相等,那么这两个类型是相同的,所以没有必要进行类型转换。
反转上面的赋值语句,我们可以看到将字符串降级为字符串字面量是不允许的:
let myString: string = 'myString';
let myStringLiteral: 'only' | 'specific' | 'strings' = 'only';
myStringLiteral = myString; // downcasting fails, not assignable
按照这种逻辑,我们可以预测在赋值期间允许哪些类型转换,除了两种特殊情况。
在可赋值性方面, never
的特殊之处在于:
-
never
可赋值给任何其他类型,并且 -
无法赋值给
never
的类型。
这意味着每个类型都可以在 never
的接收端,而 never
本身不能在任何其他类型的接收端。换句话说,从 never
向上转换到任何其他类型都是可能的(尽管参见下面的方框注释),而从 never
向下转换到任何其他类型都是不允许的,因为类型安全。因此, never
被称为底层类型,在类型理论中用 ⊥ 表示。
在集合理论中, never
是一个没有任何元素可以成为其成员的集合,并且没有任何其他集合可以成为其子集 - never
是一个空集,拒绝包含任何内容的集合。
const a: never = 1; // downcasting fails, not assignable
never 可以扩展为更广泛的类型,但是在实践中,无法提供 never 被赋值到另一个类型的示例,因为根据定义,类型 never 的值永远不会发生 —— 类型 never 的实际值永远不会用于赋值。
但是如果在实际中 never 值不可赋值,那么 never 可赋值给其他类型意味着什么?
相反的例子是 unknown
,它通常用于输入一个类型需要在使用前确认的值,例如 JSON.parse()
应该返回 unknown
,TypeScript强制我们在安全使用 unknown
值之前,先确定它的类型。
let a: unknown;
a.toUpperCase(); // still unknown, disallowed
if (typeof a === 'string') {
a.toUpperCase(); // narrowed to string, allowed
}
在可赋值性方面, unknown
的特殊之处在于:
-
每个类型都可赋值给
unknown
,并且 -
unknown
不能赋值给任何其他类型。
unknown
可以出现在任何类型的接收端,而没有任何类型可以出现在 unknown
的接收端,换句话说,从任何其他类型向上转换到 unknown
是可能的(尽管很少有用,因为 unknown
的存在要求调用者检查值的类型),而从 unknown
向下转换是禁止的,因为类型安全。
由于 unknown
在使用前需要进行类型检查(“精炼”),因此 unknown
可能是每种类型:每种类型都属于 unknown
的保护伞下。因此 unknown
被称为顶级类型,在类型理论中用符号 表示。
let x: unknown = 'a'; // upcasting succeeds, assignable
let a: unknown;
const b: string = a; // downcasting fails, not assignable
在集合理论中, unknown
是所有其他类型的超集 - 它是每个元素都是成员的集合,每个集合都是它的子集。因此, unknown
是通用集合,包含一切的集合。
总之, never
和 unknown
与其他类型相似,不允许向下转换,但与其他类型不同,从 never
向上转换和向上转换到 unknown
都是可能的,但在实践中很少发生。
any
作为逃生舱口
奇怪的是,any
是never
和unknown
的混合体。any
可以赋值给任何类型,就像never
一样,而任何类型都可以赋值给any
,就像unknown
一样。作为两种对立的混合体,any
在集合理论中没有等价物,最好将其视为禁用 TypeScript 赋值规则的逃生舱口。
第 2 部分:类型创建
我们可以使用集合操作符将现有的集合组合成一个新集合:
-
A和B的并集是至少属于A或B的所有元素的集合。
-
A和B的交集是所有同时在A和B中的元素的集合。
-
A减去B的差是A中不属于B的所有元素的集合。
-
A的补是U中不属于A的所有元素的集合。
图形:
在这四个集合操作符中,TypeScript 将两个操作符实现为类型操作符:
-
&
表示交集。
与 |
联合意味着创建一个由两种输入类型组成的更广泛、更具包容性的类型,而与 &
交叉意味着创建一个由两种输入类型共享的元素组成的更小、更具限制性的类型。
作为类型操作符, |
和 &
操作类型(集合),而不是属于这些集合的元素(值)。把类型操作符看作是函数,以类型作为输入,并返回另一种类型作为输出。
当操作基本类型时, |
和 &
的行为是可预测的:
type StringOrNumber = string | number;
// string | number → both string and number are admissible
type StringAndNumber = string & number;
// never → no type is ever admissible
但是当操作接口时, |
和 &
的行为似乎与直觉相反。
考虑这个例子:
interface ICat {
eat(): void;
meow(): void;
}
interface IDog {
eat(): void;
bark(): void;
}
declare function Pet(): ICat | IDog;
const pet = new Pet();
pet.eat(); // succeeds
pet.meow(); // fails
pet.bark(); // fails
联合类型中的 |
通常被认为意味着“A或B是可接受的”。这大致与布尔运算符 ||
在表达式中表示 OR
的事实相匹配。然而,根据 OR
来考虑接口的联合可能会产生误导。
解析输出类型 ICat | IDog
为允许“ ICat
或 IDog
的方法”,会使我们相信输出类型 ICat | IDog
将接受一个具有 ICat
或 IDog
方法的对象,但编译器不允许这样做。联合两个接口产生一个更大的集合,即一个具有更少方法的接口 - 只有输入集之间的公共方法。换句话说,联合 ICat | IDog
是一个由其输入集的共享元素组成的新输出集。
相反的,交集类型中的 &
通常被认为是“一个和另一个”。这与布尔运算符 &&
在表达式中表示 AND
的事实相匹配。但是,根据 AND
来考虑接口的交集也可能具有误导性。
考虑这个例子:
interface ICat {
eat(): void;
meow(): void;
}
interface IDog {
eat(): void;
bark(): void;
}
declare function Pet(): ICat & IDog;
const pet = new Pet();
pet.eat(); // succeeds
pet.meow(); // succeeds
pet.bark(); // succeeds
两个接口相交会产生一个更小的集合,即一个具有更多方法的接口 - 两个输入集中的所有方法。
总之,当联合接口时,从 OR
的角度思考会混淆我们的解释,而想象一个更广泛的输出集可以帮助澄清它。当交叉接口时,从 AND
的角度思考也会混淆我们的解释,而想象一个更窄的输出集可以帮助澄清它。
但是,为什么在联合和交叉接口时,我们的期望会发生逆转?
object
类型是所有可能对象形状的无限集合;接口是所有可能对象形状的无限集合,这些对象形状具有特定的属性。接口是 object
集合的子集。从所有可能对象形状的集合中,属性与接口匹配的对象形状可以赋给接口。
let a: object;
a = { z: 1 }; // { z: number } is assignable to object
因为接口描述了对象的形状,所以我们给接口添加的属性越多,匹配的对象形状就越少,因此可能的值集就越小。
interface Person {
name: string;
age: number;
isMarried: boolean;
}
当联合两个接口时,可视化两个部分重叠的完全阴影集。当我们联合时,我们创建一个输出类型,接受匹配的类型:
-
一种输入类型,或
-
其他输入类型,或
-
两者都有。
从所有可能的对象形状中,这三个对象形状可以赋值给输出类型,这使得输出类型比两个输入本身更广泛、更具包容性。只有当我们记住考虑到两者的重叠时,才有意义考虑用 OR
联合接口。
interface A {
a: 1;
}
interface B {
b: 1;
}
const x: A | B = { a: 1 }; // succeeds
const y: A | B = { b: 1 }; // succeeds
const z: A | B = { a: 1, b: 1 }; // succeeds, assignable to overlap
像 string | number
这样的基本类型的并集也会产生重叠,但是没有基本类型同时具有两种类型,所以根本没有什么可以赋给基本类型的重叠。由于我们往往忽略这种情况,我们默认认为布尔值 OR
是考虑所有类型的并集的精确方式,这可能会在操作非基本类型时误入歧途。
相反,当两个接口相交时,可视化两个部分重叠的集合,只在重叠部分进行阴影处理。当相交时,我们创建一个输出类型,该类型只接受与重叠部分匹配的类型,即只接受共享部分。
interface A {
a: 1;
}
interface B {
b: 1;
}
const x: A & B = { a: 1 }; // fails
const y: A & B = { b: 1 }; // fails
const z: A & B = { a: 1, b: 1 }; // succeeds
像 string & number
这样的基本类型的交集总是产生 never
,因为没有基本类型可以与另一个共享元素。但是接口是 object
的子集,所以接口的交集总是产生一个接口,可以同时满足两个输入对象形状。即使相交的接口没有任何共同的属性,它们也共享所有可能的对象形状的片的共同性。
同样,将我们对相交基本类型的直觉应用到相交非基本类型上会导致我们用布尔 AND
来思考,因此我们冒着错误地假设上面的 x
和 y
应该成功,而实际上它们并不成功。
累积效应:
当我们unionize不同的接口时,重叠部分会累积属性。当我们intersect不同的接口时,输出类型会累积属性。当我们声明一个接口
extends
另一个接口时,子接口会累积属性。
在这三种情况下,这种累积效应类似于接口声明合并,其中相同接口的单独声明创建了一个聚合接口,该接口累积了每个接口中的属性。
第 3 部分:条件类型的解析
在集合论中,存在一些方程,它们对集合中的所有元素都普遍成立。
交换律
结合律
分配律
总的来说,集合支持的四个操作符有十二条规则,但由于 TypeScript 只实现了 |
和 &
,所以只有十二条规则中的一部分同时适用于集合和类型,其中两对规则对于理解条件类型特别有用:同一规则和幂等规则。
同一规则
与空集并集的集合会分解为自身,与泛型集交集的集合也会分解为自身,特殊集,就像 TypeScript 中的等价物一样,会根据恒等律进行折叠:
type A = string | never; // resolves to string
type B = string & unknown; // resolves to string
幂等规则
一个集合与自身并集就分解为自身,一个集合与自身交集也分解为自身,重复的集合或类型都被幂等定律过滤掉了:
type A = string | string; // resolves to string
type B = string & string; // resolves to string
单位定律和幂等定律与条件类型的关系是什么?
如果一个类型是一个集合,那么条件类型中的条件就等于一个子集检查。一个集合是否是另一个集合的(适当的)子集?如果是,那么给定的类型可以赋给接收者类型。作为检查的焦点,我们所询问的这个给定的类型称为检查类型。
type R = 'a' extends string ? true : false; // true
type S = 'a' | 'b' extends number ? true : false; // false
type T = { a: 1; b: 2 } extends { a: 1 } ? true : false; // true
type U = { a: 1 } extends { a: 1; b: 2 } ? true : false; // false
如果被检查的类型是具体的,那么条件类型解析是简单的,但是如果被检查的类型是泛型的呢?当我们在条件类型中引入泛型时,我们通常这样做是为了能够访问被解析类型中的泛型,通常是为了过滤输入和/或转换输出:
type X<T> = T extends OtherType ? T : never;
type Y<T> = T extends OtherType ? T[] : never;
type Z<T> = T extends OtherType ? { a: T } : never;
检查泛型,然后在输出中使用。
当我们把联合类型传递给一个已检查的泛型时,条件类型解析就不再简单,而变得容易被解释。
当我们把一个union 传递给条件类型中的泛型时,我们的意思是:
-
检查联合中的每个组成部分是否是其他类型的(适当的)子集,并解析每个类型。聚合联合中所有已解析的类型。
-
检查联合作为一个整体,即作为一个单一集合,是否是其他类型的(适当)子集,并解析类型。
第一种解释是分配型条件类型,在这里,检查分布在联合的每个组成部分上,这意味着每个联合组成部分都会被问到一个问题,然后根据每个联合组成部分的答案来解析类型,这是 TypeScript 对于带有已检查泛型的条件类型的默认解析策略,该泛型将联合传递给它。
// distributive conditional type
type ToArrayDist<T> = T extends unknown ? T[] : never;
// call to distributive conditonal type
type R = ToArrayDist<string | number>; // string[] | number[]
// distribution spelled out
type R =
| (string extends unknown ? string[] : never)
| (number extends unknown ? number[] : never);
// after checks performed and types resolved - end result
type R = string[] | number[];
注意区别:
分配条件类型分配一个检查到一个并集,为每个并集成分产生一个已解析的类型。因此,分配条件类型与集分配法则无关,后者为三个不同的集重新分配交集和并集,如本节开头所示。
第二种解释,将联合作为一个整体来操作,是一个非分配的条件类型. 由于分配性是默认行为,禁用分配性需要将两个类型分别用方括号括起来. 非分配性是 TypeScript 的另一种解析策略,用于处理带有已检查泛型的条件类型,并向其传递联合。
// non-distributive conditional type
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
// call to non-distributive conditional type
type R = ToArrayNonDist<string | number>;
// after single check performed and single type resolved - end result
type R = (string | number)[];
在上面的例子中,条件总是为真(即,从未到达错误分支),以便我们能够专注于分配效果。实现总是为真的条件的其他方法是 T extends any
和 T extends T
。这些总是为真的条件对于创建分配辅助器很有用:
// distributive conditional type
type GetKeys<T> = T extends T ? keyof T : never;
// call to distributive conditional type
type R = GetKeys<{ a: 1; b: 2 } | { c: 3 }>;
// distribution spelled out
type R
| { a: 1; b: 2 } extends { a: 1; b: 2 } ? keyof { a: 1; b: 2 } : never
| { c: 3 } extends { c: 3 } ? keyof { c: 3 } : never;
// after checks performed and types resolved
type R = keyof { a: 1; b: 2 } | keyof { c: 3 };
// after keyof operator applied - end result
type R = 'a' | 'b' | 'c';
相比之下,在分配型条件类型中,true 和 false 分支都是可到达的,每个组成部分的解析都必须在输出的并集中找到重复的类型和 never
的实例,当出现重复的类型和 never
的实例时,TypeScript 应用单位和幂等法则来过滤并折叠已解析类型的并集到其最小表达式中:
// distributive conditional type
type NonNullable<T> = T extends null | undefined ? never : T;
// call to distributive conditional type
type R = NonNullable<string | string | string[] | null | undefined>;
// distribution spelled out
type R =
| (string extends null | undefined ? never : string)
| (string extends null | undefined ? never : string)
| (string[] extends null | undefined ? never : string[])
| (null extends null | undefined ? never : null)
| (undefined extends null | undefined ? never : undefined);
// after checks performed and types resolved
type R = string | string | string[] | never | never;
// idempotent laws removed unionized nevers
type R = string | string | string[];
// identity laws removed unionized duplicates - end result
type R = string | string[];
最后,要知道,对于触发可分性的条件类型,被检查的泛型必须位于 extends
的左边,也就是说,在检查期间不能传递给另一个泛型,也不能被改变。
// distributive, checked generic by itself
type R<T> = T extends OtherType ? true : false;
// non-distributive, checked generic not by itself
type R<T> = SomeType<T> extends OtherType ? true : false;
欢迎关注公众号:文本魔术,了解更多