玩转 TypeScript 类型系统

职场   2024-10-13 14:28   浙江  

类型 -> 集合

TypeScript 类型系统是一种对类型进行操作的纯函数式语言。

你可能会问,对类型进行操作意味着什么?简单的说,就是把类型解析为项目集合:集合将包含可分配给该类型的每个实值。

事实证明相当好用。

我们知道,TypeScript 的核心语法是操作给定集合中的项目,就像普通编程语言操作真实集合那样。

如果我们将类型视为字面量(实值)集合,那么可以把string当作是字符排列的无限集合,把number当作是数字排列的无限集合。

这样一来,类型系统就可视为处理集合的函数式编程语言,难以理解的高级功能就变得简单多了。

本文将从以下角度介绍 TypeScript 功能:类型是可以创建的集合,TypeScript 是一种对集合进行操作的函数式编程语言。

注意,集合和类型不是等效的。

分解 TypeScript 基元

交集(&)

交集(&)的模型可以帮助我们更好地理解操作。请看以下示例:

type Bar = { x: number }; 
type Baz = { y: number }; 
type Foo = Bar & Baz;

这里的操作就是把BarBaz相交。我们的第一直觉会这样应用交集的操作:

2 个对象重叠的地方就是交集的结果。但,这里没有重叠的内容,怎么办?

虽然都是数字,但左侧(LHS)只有x,右侧(RHS)只有y。那么为什么产生交集类型呢:

let x: Foo = { x: 2, y: 2 };

这是怎么回事呢?让我们分别解析类型BarBaz为集合。

当我们定义{ y:number }的类型时,可以构造一组无限的对象字面量,这些对象字面量共同点是都具有y属性,其中y为数字:

用集合替换类型,交集就有意义了:

并集

为了简单起见,我们用之前构建的模型,只取 2 个集合的并集。

type Foo = { x: number }; 
type Baz = { y: number }; 
type Bar = Foo | Baz;

类型检查

TypeScript 中的基元允许我们方便地检查集合。

例如,我们可以检查一个集合是否是另一个集合的子集,方法就是使用extends关键字在true/false的情况下返回新集合。

type IntrospectFoo = number | null | string extends number 
"number | null | string constructs a set that is a subset of number" 
"number | null | string constructs a set that is not a subset of number"

// IntrospectFoo = "number | null | string is not a subset of number"

这样我们就可以检查 LHS 集合是否是 RHS 集合的子集了。

强大吧,因为我们可以任意嵌套。

type Foo = null 
type IntrospectFoo = Foo extends number | null 
? Foo extends null 
"Foo constructs a set that is a subset of null" 
"Foo constructs a set that of number"
 : "Foo constructs a set that is not a subset of number | null"

// Result = "Foo constructs a set that is a subset of null"

但是当我们将并集作为类型参数传递时,就有点奇怪了。TypeScript 不是将并集解析为构造集合,而是会对并集的每个成员单独执行子集检查。

因此,我们需要更改前面的示例,来使用类型参数:

type IntrospectT<T> = T extends number | null
  ? T extends null
    ? "T constructs a set that is a subset of null"
    : "T constructs a set that of number"
  : "T constructs a set that is not a subset of number | null";
type Result = IntrospectT<number | string>;

Typescript 会将Result转换为:

type Result = IntrospectFoo<number> | IntrospectFoo<string>;

Result解析为:

"T constructs a set containing only number" | "T constructs a set with items not included in number | null";

这对于大多数操作来说更为方便了。但是,我们可以也使用元组语法强制 TypeScript 不这样做:

type IntrospectFoo<T> = [T] extends [number | null]
  ? T extends null
    ? "T constructs a set that is a subset of null"
    : "T constructs a set that of number"
  : "T constructs a set that is not a subset of number | null";
type Result = IntrospectFoo<number | string>;
// Result = "T constructs a set that is not a subset of number | null"

因为我们不再将条件类型应用于并集,而是应用于恰好内部有并集的元组。

这个边缘情况很重要,可以让我们意识到将类型解析为集合并不完美。

类型映射

在普通编程语言中,你可以通过迭代集合来创建新的集合。

例如,在 python 中,如果要展平一个元组集,可以执行以下操作:

nested_set = {(1,3,5,6),(1,2,3,8), (9,10,2,1)}
flattened_sed = {}
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 is not a subset of Array<unknown>";
type TheNumberInside = InsideArray<Array<number>>;
// TheNumberInside = number

语句了执行以下操作:

  • 检查T是否是集合Array<any>构造的子集(R尚不存在,因此这里为any
    • 推断什么类型会构造R',将该类型放在R中,其中R仅在true分支中可用
    • 返回R作为最终类型
    • 如果是,则对于集合T中的每个数组,将每个数组的项放入一个名为R'的新集合中
    • 如果不是,提供错误消息

我们可以将此过程直观地描述为:

在这个模型中, TypeScript 之所以使用infer,是因为它会自动找到一个类型来描述如何创建集合 - R'

类型转换 - 映射类型

映射类型有一个非常简单的初始用法,映射集合中的每个项目来创建对象类型。

例如:

type OnlyBoolsAndNumbers = {
  [key: string]: boolean | number;
};

最后一步我直接说一下,不写了 - 将对象类型映射回集合。

我们还可以映射字符串的子集:

type SetToMapOver = "string" | "bar";
type Foo = { [K in SetToMapOver]: K };

这儿,我们映射集合[“string”, “bar”]来创建对象类型 => {string:“string”, bar:“bar”}

我们可以对对象类型的keyvalue进行各种计算:

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

重复逻辑

如果我们想对集合执行转换,该怎么办?因为在移动到下一项之前,需要多次运行内部计算,所以在运行时编程语言中,我们自然而然地就会使用for循环。

但是,由于 TypeScript 的类型系统是一种函数式语言,递归成为我们第一选择。

type FirstLetterUppercase<T extends string> =
  T extends `${infer R}${infer RestWord} ${infer RestSentence}`
    ? `${Uppercase<R>}${RestWord} ${FirstLetterUppercase<RestSentence>}` // recurssive call
    : T extends `${infer R}${infer RestWord}`
    ? `${Uppercase<R>}${RestWord}` // base case
    : 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) {
    // base case
    return "";
  }
  const [firstWord, restWords] = separateFirstWord(t);
  return `${firstWord[0].toUpperCase()}${firstWord.slice(1)} ${firstLetterUppercase(restWords)}`// recursive call
};

我们得到的是当前句子的第一个单词,大写单词的第一个字母,然后对其余单词执行相同的操作,再把它们连接起来。

将运行时示例与类型级别示例进行比较:

  • 用于生成基本情况的if语句替换为if子集检查(extends
    • 看起来很像if语句,因为使用inferRRestWordRestSentence)创建的每个集合仅包含一个字符串文字
  • 使用解构拆分句子的第一个单词,句子的其余部分被替换为集合-${infer R}${infer RestWord} ${infer RestSentence}-的infer映射
  • 函数参数替换为类型参数
  • 递归函数调用替换为递归类型实例化

类型系统可以用于描述任何计算。Nice。

总结

TypeScript 这种对集合进行操作的方式,不但方便我们使用这些集合来强制执行严格的编译时检查,而且还能帮助我们尽早发现更多的错误。

虽然本文拿来举例子的模型并不完美,但管中窥豹,可以帮助我们更加深刻理解 TypeScript 的一些高级功能,用来学习很不错哦。

前端新世界
关注前端技术,分享互联网热点
 最新文章