类型 -> 集合
TypeScript的类型系统可以被看作是一个纯粹函数式语言,它在类型上操作。但是,在类型上操作意味着什么?对我来说,我发现将类型解析为它可以构造的项目集合非常有用。这个集合将包含每个可以分配给该类型的实际值。
然后TypeScript的核心语法是操作给定集合中项目的功能性,就像你在普通编程语言中可能对真实集合进行操作一样。
因为TypeScript是一个结构化类型系统,与名义上的不同,这个类型构造的“集合”可能比实际类型定义本身更有用(但并不总是这样)。
如果我们将每种类型视为它可以构造的字面量集合——实际值,我们可以说一个字符串只是每个字符排列的无限集合,或者一个number
是每个数字排列的无限集合。
一旦你开始将类型系统视为一个旨在仅处理集合的适当的函数式编程语言,更高级的特性就会变得更容易理解一些。
本文将通过以下视角介绍TypeScript的大多数特性:类型是它们可以创建的集合,TypeScript是一个在集合上操作的函数式编程语言。
注意 我不是在暗示集合和类型是等价的,它们不是。
分解TypeScript基础类型
交集 (&)
交集(&)是一个很好的例子,这种思维模型帮助你更好地理解操作。给定以下示例:
type Bar = { x: number };
type Baz = { y: number };
type Foo = Bar & Baz;
我们正在交叉Bar和Baz。你的第一反应可能是交集操作是这样应用的:
我们确定两个对象之间的重叠部分,并将其作为结果。但是…没有重叠?左侧(LHS)只有x,右侧(RHS)只有y,尽管都是数字。那么为什么交集会导致一个类型,允许这样:
let x: Foo = { x: 2, y: 2 };
一个更容易理解正在发生的事情的方法是将类型Bar
和Baz
解析为它们构造的集合,而不是文本中的样子。
当我们定义一个类型{ y: number }
时,我们可以构造一个无限集合的对象字面量,这些对象至少包含属性y,其中y是一个数字:
注意: 请注意我说的是“至少包含属性y的对象类型的集合”。这就是为什么一些对象类型中存在除y之外的其他属性。如果你有一个类型为
{y: number}
的变量,如果对象内部有除y之外的更多属性,这对你是无关紧要的,这就是为什么TypeScript允许它。
现在我们知道如何用它们构造的集合替换类型,交集就变得更有意义了:
联合
使用我们建立的前一个思维模型,这是微不足道的,我们只需要取两个集合的并集以获得我们的新集合
type Foo = { x: number };
type Baz = { y: number };
type Bar = Foo | Baz;
类型内省
因为TypeScript的维护者认为这会很方便,他们在语言中构建了基础类型,让我们可以内省这些集合。例如,我们可以检查一个集合是否是另一个集合的子集,并在真/假的情况下使用extends
关键字返回一个新的集合。
type IntrospectFoo = number | null | string extends number
? "number | null | string 构造的集合是 number 的子集"
: "number | null | string 构造的集合不是 number 的子集";
// IntrospectFoo = "number | null | string 不是 number 的子集"
我们正在检查extends关键字左侧集合是否是右侧集合的子集。
这是非常强大的,因为我们可以任意嵌套这些
type Foo = null
type IntrospectFoo = Foo extends number | null
? Foo extends null
? "Foo 构造的集合是 null 的子集"
: "Foo 构造的集合是 number 的子集"
: "Foo 构造的集合不是 number | null 的子集";
// 结果 = "Foo 构造的集合是 null 的子集"
但是当使用类型参数并将联合作为类型参数传递时,事情变得奇怪。TypeScript决定在类型参数使用时,对联合的每个成员单独执行子集检查,而不是首先将联合解析为构造的集合。
所以,当我们稍微修改前面的例子使用类型参数:
type IntrospectT<T> = T extends number | null
? T extends null
? "T 构造的集合是 null 的子集"
: "T 构造的集合是 number 的子集"
: "T 构造的集合不是 number | null 的子集";
type Result = IntrospectT<number | string>;
TypeScript将把Result
转换为:
type Result = IntrospectFoo<number> | IntrospectFoo<string>;
使Result
解析为:
"T 构造的集合仅包含 number" | "T 构造的集合包含不在 number | null 中的项目";
这只是因为这对于大多数操作来说更方便。但是,我们可以使用元组语法强制TypeScript不这样做
type IntrospectFoo<T> = [T] extends [number | null]
? T extends null
? "T 构造的集合是 null 的子集"
: "T 构造的集合是 number 的子集"
: "T 构造的集合不是 number | null 的子集";
type Result = IntrospectFoo<number | string>;
// Result = "T 构造的集合不是 number | null 的子集"
这是因为我们不再在联合上应用条件类型,我们将其应用于一个元组,该元组恰好内部有一个联合。
这个边缘情况很重要,因为它表明总是将类型解析为它们构造的集合的思维模型并不完美。
类型映射
在正常的编程语言中,你可以迭代一个集合(无论该语言如何实现),以创建一个新的集合。例如,在Python中,如果你想要展平一个元组集合,你可能会这样做:
nested_set = {(1,3,5,6),(1,2,3,8), (9,10,2,1)}
flattened_set = {}
for tup in nested_set:
for integer in tup:
flattened_set.add(integer)
我们的目标是在TypeScript类型中这样做。如果我们认为:
Array<number>
作为包含数字的所有排列数组的集合:
我们想要应用一些转换来选择每个项目中的数字并将它们放在集合中。
我们不是使用命令式语法,而是可以在TypeScript中声明性地做到这一点。例如:
type InsideArray<T> = T extends Array<infer R>
? R
: "T 不是 Array<unknown> 构造的集合的子集";
type TheNumberInside = InsideArray<Array<number>>;
// TheNumberInside = number
这个语句执行以下操作:
- 检查T是否是集合
Array<any>
构造的子集(R尚不存在,所以我们用any替换它)- 如果是,对于集合T构造的每个数组,将每个数组的项目放入一个名为R'的新集合中
- 推断出什么类型会构造R',并将该类型放在R中,R仅在真分支中可用
- 返回R作为最终类型
- 如果不是,提供一条错误消息
- 如果是,对于集合T构造的每个数组,将每个数组的项目放入一个名为R'的新集合中
注意 这不是基于infer如何实现的规范,这只是用集合的思维模型来推理infer如何工作的一种方式
我们可以将这个过程视觉化描述为:
有了这个思维模型,TypeScript使用infer
这个词实际上是有意义的。它自动找到一个类型,该类型将描述我们创建的集合——R。
类型转换-映射类型
我们刚刚描述了TypeScript如何允许我们非常精确地检查一个集合是否看起来像某样东西,并基于此映射它们。然而,如果我们能够更表达性地描述集合中每个由类型构造的项目看起来如何,那将是有用的。如果我们能够很好地描述这个集合,我们就可以做出我们想要的任何东西:
映射类型是一个很好的例子,它有一个非常简单的初始用途,即遍历集合中的每个项目以创建一个对象类型。
例如:
type OnlyBoolsAndNumbers = {
[key: string]: boolean | number;
};
最后一步将在我们的脑海中完成——将对象类型映射回集合
我们也可以遍历字符串的一个子集:
type SetToMapOver = "string" | "bar";
type Foo = { [K in SetToMapOver]: K };
在这里我们遍历集合["string", "bar"]
来创建一个对象类型 => {string: "string", bar: "bar"}
,然后描述一个可以构造的集合。
我们可以对对象类型的键和值执行任意类型级别的计算:
type SetToMapOver = "string" | "bar";
type FirstChacter<T> = T extends `${infer R}${infer _}` ? R : never;
type Foo = {
[K in SetToMapOver as `IM A ${FirstChacter<K>}`]: FirstChacter<K>;
};
注意:
never
是空集合——集合中不存在任何值——所以具有类型never的值永远不能被分配任何东西
现在我们遍历集合["string", "bar"]
来创建新类型 =>
{["IM A s"]: "s", ["IM A b"]: "b"}
重复逻辑
如果我们想要对集合执行一些转换,但转换很难表示。它需要在移动到下一项之前运行其内部计算任意次数。在运行时编程语言中,我们会毫不费力地寻找循环。但是,由于TypeScript的类型系统是一个函数式语言,我们将寻求递归
type FirstLetterUppercase<T extends string> =
T extends `${infer R}${infer RestWord} ${infer RestSentence}`
? `${Uppercase<R>}${RestWord} ${FirstLetterUppercase<RestSentence>}` // 递归调用
: T extends `${infer R}${infer RestWord}`
? `${Uppercase<R>}${RestWord}` // 基本情况
: never;
type UppercaseResult = FirstLetterUppercase<"upper case me">;
// UppercaseResult = "Upper Case Me"
现在首先…哈哈什么。这看起来可能很疯狂,但它只是密集的代码,并不复杂。让我们编写一个TypeScript运行时版本来扩展发生了什么:
const separateFirstWord = (t: string) => {
const [firstWord, ...restWords] = t.split(" ");
return [firstWord, restWords.join(" ")];
};
const firstLetterUppercase = (t: string): string => {
if (t.length === 0) {
// 基本情况
return "";
}
const [firstWord, restWords] = separateFirstWord(t);
return `${firstWord[0].toUpperCase()}${firstWord.slice(1)} ${firstLetterUppercase(restWords)}`; // 递归调用
};
我们得到当前句子的第一个单词,大写单词的第一个字母,然后对其余的单词做同样的事情,在过程中将它们连接起来。
将运行时示例与类型级别示例进行比较:
- 生成基本情况的if语句被替换为子集检查(
extends
)- 这看起来非常像if语句,因为使用
infer
构造的每个集合(R
,RestWord
,RestSentence
)只包含一个字符串字面量
- 这看起来非常像if语句,因为使用
- 使用解构将句子分割为第一个单词和其余句子,被替换为
infer
映射到3个集合——${infer R}${infer RestWord} ${infer RestSentence}
- 函数参数被替换为类型参数
- 递归函数调用被替换为递归类型实例化
我们有能力使用这些能力描述任何计算(类型系统是图灵完备的)。
结论
如果你能够将TypeScript视为一种非常表达性的方式来操作集合,并使用这些集合来执行严格的编译时检查,你很可能会开始对高级TypeScript特性感到更加舒适(如果还不是的话),允许你尽早捕捉到更多的错误。
这个思维模型并不完美,但即使在TypeScript的一些最高级特性中,它也相当站得住脚。