类型就是数据
每种编程语言都是关于转换数据的,Type-level TypeScript 也不例外。与其他编程语言的主要区别在于我们的数据是类型!我们编写将类型作为输入并输出一些其他类型的程序。
掌握这门语言需要你深入的理解它的不同数据和不同数据结构的特性。在接下来几章中,我们将学习它们并了解它们与我们常用的 value-level 的概念之间的关联。让我们开始吧!
五种类型
TypeScript 提供了五种主要的类型:基础类型、字面量类型、数据结构类型、联合类型和交集类型。
基础类型
你肯定已经非常熟悉基础类型了。我们一直使用它们来注释我们日常 TypeScript 代码中的变量和函数。以下是原始类型的列表:
type Primitives =
| number
| string
| boolean
| symbol
| bigint
| undefined
| null;
JavaScript 里除了对象和函数以外的其他值,都属于基础类型。某些基础类型拥有无限个值,像 number 和 string ,但是其中 null 和 undefined 类型只拥有一个值。 这些特性同时造就了我们第二种类型:字面量类型。
字面量类型
字面量类型是“精确”类型,只包含一个可能的值。
type Literals =
| 20
| "Hello"
| true
| 10000n
/* | ... */;
类型为20的变量只能被赋值为20,类型为 "Hello" 的变量只能被赋值为 "Hello",等等。
const twenty: 20 = 20; // ✅ works!
const hundred: 20 = 100;
// ^ ❌ `100` isn't assignable to the type `20`.
字面量类型有无数种,它们看起来就像普通的值,但是不要搞混了,它们都是类型!
值和类型属于两个完全不同的世界 —— 他们彼此独立存在,在单个表达式里不能被混在一起1。 我发现,如果我们将字面量类型视为类型世界里值的一种映射,将会很有帮助。但是需要时刻记住,类型和值它们是不同的东西。一个显著的区别是,我们无法在 type-level 书写数学表达式。比如,type five = 2 + 3 是完全无效的,但是 const five = 2 + 3 是完全有效的。
当放在联合类型中来描述一组有限可能的类型时,字面量类型非常有用,比如 type TrafficLight = "green" | "orange" | "red" 。
数据结构类型
在 type-level 世界里,我们拥有四种内置的数据结构可供使用:objects, records, tuples and arrays。
type DataStructures =
| { key1: boolean; key2: number } // objects
| { [key: string]: number } // records
| [boolean, number] // tuples
| number[]; // arrays
- Object 类型用于描述一组有限健值的对象,这些键可能包含不同类型的值。
- Record 类型和 Object 类型相似,不同之处在于它描述拥有未知数量的健值的对象,并且所有健的值的类型都是相同的。比如,在
{ [key: string]: number }里,所有值类型都是 number 。 - Tuple 类型用于描述固定长度的数组,每个索引可以对应不同的值。
- Array 类型用于描述长度不定的数组,和 Record 类型一样,其中所有的值都是相同类型的。
在接下来的章节中,我们将花更多时间学习它们。
联合类型和交叉类型
到目前为止,我们所看到的一切都与 value-level 中所用到的概念相似,但是联合类型和交叉类型不一样。它们只存在于 type-level 世界,建立一个良好的心智模型来学习它们是非常重要的,尽管这充满着挑战。
它们看起来像这样:
type Union = X | Y;
type Intersection = X & Y;
简单来说,你可以理解联合类型 X | Y 为“一个值的类型要么属于类型 X 要么属于类型 Y”,交叉类型 X & Y 为“一个值的类型即属于类型 X 又属于类型 Y”。
我们倾向于把 | 和 & 视为操作符,但是事实上,它们也是数据结构。
创建联合类型 X | Y 不会像操作符那样创建一个新的不透明的类型。相反,它将 X 和 Y 按序放在一个盒子里,之后我们可以从中提取它们。在接下来的章节里,我们将会看到,我们甚至可以遍历联合类型中的每种类型。考虑到这点, | 看起来更像是一种将类型添加到某种 “联合”数据结构 的方法。但它到底是什么?
好吧,我们可以说 type-level 中的联合类型等价于 Javascript 中的 Set , 但实际上的情况更复杂一些。为了更好的掌握联合类型和交叉类型,我需要介绍一个 type-level 中的基础概念:所有类型都是集合。
类型就是集合
TypeScript 的一个有趣特性是一个值可以属于多个类型。例如,值 2 可以赋给 number 类型的变量,也可以赋给 2 类型的变量,甚至是 1 | 2 | 3 类型的变量. 此特性称之为子类型。这意味着类型可以包含在其他类型中,或者换句话说,类型可以是其他类型的子集。
那意味着,不仅仅是集合,其它所有类型都是集合!类型可以包含其它类型,相互重叠或者相互排斥。
例如,字面量 "H1" 和 字面量 "Hello" 都被 string 类型所包含,因为它们都是组成 strings 这个大家庭中的一部分:
我们说 “Hi” 和 “Hello” 是 string 的子类型,而 string 是它们的超类型。这意味着您可以将 “Hi” 或 “Hello” 类型的变量分配给 string 类型的变量,但反过来不行:
let hi: "Hi" = "Hi";
let hello: "Hello" = "Hello";
let greeting: string;
greeting = hi; // ✅ type-checks!
greeting = hello; // ✅ type-checks!
hello = greeting; // ❌ doesn't type-check!
我们也可以说 "H1" 和 "H1" 可以分配给 string 类型。
可分配 这个概念在 Typescript 中无处不在。大多数的类型错误都会告诉你,这个类型不能分配给其它类型。当你开始认为类型就是集合时,可分配性变得更加直观 —— “ A 可以分配给 B ” 意味着 “集合 B 包含所有 集合 A 中的值” 或者 “集合 A 是集合 B 的子集”
string 类型和 number 类型是互斥的:它们没有重叠的部分,因为没有值同时属于它们两个集合。
这就是为什么你不能将 string 类型的变量分配给 number 类型的变量,反之亦然:
let greeting: string = "Hello";
let age: number = greeting; // ❌ doesn't type-check.
最后,两种类型有时会部分重叠。在这种情况下,它们既不互斥也不具有子类型关系:
这通常发生在使用联合类型时。例如类型 "green" | "orange" 和类型 "orange" | "red" 是部分重叠的!
让我们为这两种联合类型命名:
type CanCross = "green" | "orange";
type ShouldStop = "orange" | "red";
现在,我们可以思考一下,CanCross 类型的变量分配给 ShouldStop 类型的变量吗?
let canCross = "orange" as CanCross; // ✅
let shouldStop = "orange" as ShouldStop; // ✅
canCross = shouldStop;
// ❌ ~~~~~~~~~ type 'red' isn't assignable to the type `green` | 'orange'
shouldStop = canCross;
// ❌ ~~~~~~~ type 'green' isn't assignable to the type `orange` | 'red'
不能。即使 CanCross 和 ShouldStop 都包含 string 类型 "orange" ,我们也不能将 CanCross 类型的变量分配给 ShouldStop 类型,因为它们没有完全重叠。
在上面的例子中,我们可以清楚的看到, canCross 和 shouldStop 都包含值 "orange" ,但是你不能把它赋值给另一个,这可能很违反直觉。请记住, Typescript 并不知道变量包含什么值,它只知道它的类型!
联合类型-集合的并集
如果你了解一点集合的理论知识,你就会知道两个集合的并集是包含这两个集合的集合,所以 A | B 类型包含了所有 A 类型和 B 类型中的所有可能值。
我们可以将任意两个集合合并,包括其它的联合类型!例如,我们可以将上一个示例中的 CanCross 和 ShouldStop 合并为 TrafficLight 类型:
// this is equivalent to "green" | "orange" | "red"
type TrafficLight = CanCross | ShouldStop;
let canCross: CanCross = "green";
let shouldStop: ShouldStop = "red";
let trafficLight: TrafficLight;
trafficLight = shouldStop; // ✅
trafficLight = canCross; // ✅
TrafficLight 类型是 CanCross 和 ShouldStop 的超集。需要注意, "orange" 在 TrafficLight 类型中只有一个。这是因为集合类型不能出现重复项,所以联合类型也不能包含重复项。
联合类型有助于创建明确的类型的嵌套层次结构。并且我们总是可以将两种类型放在一个联合类型中,因此我们可以创建任意数量的子类型级别:
此时,你可能会想,如果所有类型都可以属于其他类型,那么这种嵌套类型的层次结构往上到哪里为止呢?有没有一种终极类型,包含了所有其它的类型?
嗯,有这样一种类型,它叫 unkown 。
unknown — 所有类型的超集
unknown 包含了你在 Typescript 中使用的所有类型。
您可以将任何内容分配给 unknown 类型的变量:
let something: unknown;
something = "Hello"; // ✅
something = 2; // ✅
something = { name: "Alice" }; // ✅
something = () => "?"; // ✅
这很不错,但是这也意味着你不能对类型为 unkown 的变量做很很多其它操作,因为 Typescript 并不知道它包含了什么值!
let something: unknown;
something = "Hello";
something.toUpperCase();
// ^ ❌ Property 'toUpperCase' does not exist
// on type 'unknown'.
任何类型 A 与 unkown 类型进行合并,总是会得到 unkown 类型。这是合理的,因为根据定义,类型 A 肯定包含在 unkown 里:
A | unknown = unknown
但是如果取交集呢?
任何类型 A 和 unkown 类型进行交集运算会得到类型 A:
A & unknown = A
那是因为将集合 A 与集合 B 相交意味着提取 A 中也属于 B 的部分!由于任何类型 A 都在 unknown 内部,因此 A & unknown 就是 A。
交叉类型
交叉类型和联合类型正好相反:A & B 是同时属于 A 和 B 的所有值的类型:
交叉类型用来处理对象类型很方便,因为对象类型 A 和 对象类型 B 的交集是具有 A 所有属性和 B 所有属性的对象集合:
这就是为什么我们有时会使用联合类型来将对象类型合并在一起2:
type WithName = { name: string };
type WithAge = { age: number };
function someFunction(input: WithName & WithAge) {
// `input` is both a `WithName` and a `WithAge`!
input.name; // ✅ property `name` has type `string`
input.age; // ✅ property `age` has type `number`
}
但是,如果我们尝试将两种完全不重叠的类型相交会发生什么?例如,将 string 和 number 相交意味着什么?
看起来 string & number 会给我们带来某种类型错误,但事实上,这对类型检查器来说很重要!不重叠的相交类型的结果是空集。一个不包含任何东西的集合。
在 TypeScript 中,空集被称为 never。
never — 空集
never 类型不包含任何值,因此我们可以使用它来表示在运行时不应该存在的值。例如,一个总是抛出异常的函数将返回一个 never 类型的值:
function panic(): never {
throw new Error("🙀");
}
const oops: never = panic();
那是因为永远无法执行到使用 oops 的代码!
很高兴知道这点,但是在实践中 never 听起来好像没什么用,你不觉得嘛?
你肯定会感觉到惊讶!我们书写 type-level 代码时,无时无刻都在使用 never。 never 本质上是一个空的联合类型。拥有一个空类型对于编写 type-level 的逻辑非常有用。我们可以用它从对象类型中删除键,从联合类型中过滤不需要的类型,以及表示不肯能存在的情况,等等。
never 有一个有趣的特性,它是所有类型的子类型 —— 它位于集合嵌套层级的最底部。那也就意味着你可以将 never 类型的值赋值给其它任何类型:
const username: string = panic(); // ✅ TypeScript is ok with this!
const age: number = panic(); // ✅ And with this.
const theUniverse: unknown = panic(); // ✅ Actually, this will always work.
如果你在联合类型中使用 never,则什么也不会改变:
type U = "Hi" | "Hello" | never;
// is equivalent to:
type U = "Hi" | "Hello";
这就像将一个空集与另一个集合合并。任何类型 A 与 never 的并集都等于 A:
A | never = A
但是,如果您将类型 A 与 never 相交,你只会得到 never:
A & never = never
那是说得通的!没有任何东西能与空集相交。
关于 any ?
您可能已经注意到,到目前为止我给出的所有示例都省略了 any 。为什么呢?
每个人都知道在 TypeScript 中使用 any 被认为是一种不好的做法。它被用作绕过类型检查的逃生舱口。虽然有时我们还是会使用它,可能是因为我们不知道如何正确书写一段代码,或者是因为我们没有足够的时间或者没有的正确方法来修复类型错误。但是把 any 类型放在我们心智模型的那个位置适合呢,以及为什么它会绕过类型检查呢?
老实说,any 不适合我们的心智模型,因为它不适用于集合理论。 any 是所有其他 TypeScript 类型的超类型和子类型。到处都是:
我知道,对吧?🤷
除了本身没有多大意义之外,any 还会在代码库中传播,因为一旦您在类型表达式中使用它,它的计算结果为 any:
A | any = any
A & any = any
any 是一个奇怪的类型。我们不会在 type-level 上大量使用它,除非在一些影响不大的特殊地方,比如类型约束,或者在条件类型的右侧,但现在让我们把它放在一边。
总结
多么棒的一章!我们已经介绍了成为 TypeScript 专家的一些最重要的概念。让我总结一下到目前为止我们学到的东西:
- 在我们的类型级程序中,类型只是数据。
- 类型有 5 种主要类别:基础类型、字面量类型、数据结构类型、联合类型和交集类型。
- 类型是集合。一旦你理解了这个概念,一切都开始变得有意义!
- 联合类型是将集合合并在一起的数据结构。
unknown是最终的超集 —— 它包含所有其它类型。never是空集 —— 它包含在所有其它类型中。。any很奇怪,因为它是每种类型的子集和超集。
挑战!
让我们练习一下!如果您还没有准备好,请查看上一章中的“How challenges work”部分,了解如何应对这些挑战。