2022/01/16

集合视角下的 TypeScript 泛型开发实践

前段时间我钻研了 《How to master advanced TypeScript patterns》 这篇文章,这是 ts-toolbelt 的作者 Pierre-Antoine Mills 的一篇早期博客文章。文章提出了一个很有挑战的题目:TS 如何为柯里化函数编写类型支持?

我参考原文进行了一些实践,然后似乎领悟到一些关于 TS 泛型的更接近实质的知识 —— 从集合的视角。基于这些知识,我发现原文中的大部分泛型都有更严密的写法。我认为这次实践和思考的过程值得记录下来,因此有了本文。

和原文一样,本文不展开讨论柯里化或函数式编程的问题,柯里化只是用以讨论 TS 泛型开发的素材。本文的线索是我的这份完整实现中,每一个泛型的作用,但这不是重点,重点是背后的领悟 —— 在文章前半部分,我常常会围绕一条简单的泛型讨论较长篇幅,请不要直接跳过。

命题

柯里化是函数式编程领域的一个重要概念,它表示这样的过程:把一个多参数的函数转化成「一次接收一个参数」的函数,比如把 f(a,b,c) 转化为 f(a)(b)(c)。举个更详细的例子:

// toCurry 函数为待柯里化的普通函数
declare const toCurry: (a1: 1, a2: 2, a3: 3, a4: 4) => 0;

// curry 是柯里化转换函数,接收普通函数 toCurry,返回转换后的函数(先用 unknown 类型表示)
declare const curry: (toCurry: Function) => unknown;

// curried 是柯里化转换后的函数,调用者按次序每次传入一个参数,所有参数传入后,得到最终的返回值
const curried = curry(toCurry);
curried(1)(2)(3)(4);            // => 0

最简单的柯里化,一次仅能接收一个参数。

高级的柯里化,一次可以接收不定项个参数:

// 调用 curried 一次传入多个参数
curried(1)(2,3)(4);             // => 0

甚至还可以支持剩余参数和占位符:

// toCurry 中包含剩余参数
declare const toCurry: (a1: 1, a2: 2, a3: 3, a4: 4, ...args: 5[]) => 0;
const curried = curry(toCurry);

// 调用 curried 时也可以传入剩余参数
curried(1)(2, 3)(4, 5, 5, 5, 5);    // => 0

// 调用 curried 时通过传入占位符把参数 2 移到了 3 之后
curried(1)(__, 3)(2, 4, 5);     // => 0

柯里化转换函数 curry 是柯里化的核心。转换函数接收一个普通函数 toCurry —— 暂时用 Function 类型来表示;并返回柯里化转换后的函数 curried(后面也称柯里化的函数柯里化函数)—— 暂时用 unknown 类型来表示。如何写出它的合法的类型表达,就是这篇文章的主线。

CurriedV1:最简单的柯里化

最简单的,一次只接收一个参数的柯里化,我的实现如下:

type Length<T extends unknown[]> = T['length'];

type Head<T extends unknown[]> = T extends [] ? never : T[0];

type Tail<T extends unknown[]> = T extends [] ? never : T extends [unknown, ...infer R] ? R : T;

type CurriedV1<P extends unknown[], R> = P extends [] ? R : (arg: Head<P>) => CurriedV1<Tail<P>, R>;

type Curry = <P extends unknown[], R>(fn: (...args: P) => R) => CurriedV1<P, R>;

declare const curry: Curry;
declare const toCurry: (a1: 1, a2: 2, a3: 3, a4: 4) => 0;

const curried = curry(toCurry);
curried(1)(2)(3)(4); // => 0

泛型 Length

第一条泛型 Length 返回元组的长度。

type Length<T extends unknown[]> = T['length'];

首先需要领悟的是,类型是对象的集合。比如,类型 number 是所有数字的集合,类型 1 表示由数值 1 组成的单一元素集合,类型 string[] 是所有「每一项都是字符串的数组」的集合。

泛型,从形式上看,是类型的函数(把一种类型转化为另一种类型);从集合的角度看,是集合的映射(把一个集合变为另一个集合)。

集合的映射,必须基于作用于集合内元素规则。假设有集合 A 和 B,只有当 A 中的任意元素,按照某种规则,可以转化为 B 中的一个元素,我们才能说 A 和 B 之间存在映射关系。

既然映射只能从一个集合映射到另一个集合,如何理解多个泛型参数的情况?回答:把多个泛型参数看作一个元组类型。

Length 为例:

type Length_Test1 = Length<[unknown]>;          // => 1
type Length_Test2 = Length<unknown[]>;          // => number

将类型 [unknown] 传递给 Length 得到类型 1,描述了这样的事实:属于类型 [unknown] 的无数个元素中任意一个,不管是 [1] 还是 ['foo'] 还是 [{foo: true}]对它求取长度,得到的结果都是 1。这些元素经过 Length 这条规则,都被转化成了数值 1 这个元素;换言之,类型 [unknown] 所代表的集合通过 Length 这条规则映射为只包含一个元素(也就是 1 这个数值)的单元素集合,这个集合对应的类型就是类型 1

将类型 unknown[] 传递给 Length 得到类型 number,这是因为:属于类型 unknown[] 的无数个元素中的任意一个,不管是 [] 还是 [1] 还是 ['foo', true]对它求取长度,得到的结果 012 等等,都是 number 类型。注意,Length 不能保证经过规则转换后的值占满映射得到的集合:因为没有什么数组的长度是 0.5-1。所以,泛型运算 Length<unknown[]> 的结果 number 其实是真实世界中映射得到的结果集的超集

泛型返回「真实结果集」的超集,时常比我们预期的集合要宽泛,这是不可避免的。从集合的角度看,编写泛型的目的,就是提供「真实结果集」的可以用类型规则描述的,同时尽可能小的超集。理解这一点很重要。

如果 JS 支持无符号整数类型,Length<unknown[]>,似乎就可以得到完美的结果集,但这其实只是巧合。更多场合是无法得到完美结果集的:比如 Length<[unknown, ...unknown[]]> 需要返回「由大于 1 的整数」构成的集合。

JavaScript:先验性的知识

TS 是如何知道 Length<unknown[]> 的结果是 number 的呢?在 Length<unknown[]>number 之间,是否还存在某种原理可以被理解呢?我认为:已经没有什么原理性的内容了,TS 仅仅是根据先验性(公理性)的知识来直接给出答案

TS 的类型系统是为 JS 量身定制的:任意 JS 字面量都是 TS 的单元素类型;JS 的基础类型如 numberstring 也构成了 TS 的基础类型;通过类似定义数组、JSON 对象、函数的语法,我们可以创建数组类型和元组类型、对象类型、函数类型,来表示包含符合条件的数组元素、对象元素或函数元素的集合。TS 当然熟悉 JS 里所有对象类型的习性 —— 它们有什么成员属性,它们之间如何转化等等 —— 这些知识对 TS 来说是先验性的,所以 TS 才能轻易且正确地进行基础类型的运算。

// 基础类型间的运算
type T1 = number['toFix'];                      // => () => string
type T2 = [number, string][1];                  // => string
type T3 = keyof { foo: number, bar: string };   // => 'foo' | 'bar'

泛型 Head

第二条泛型 Head 取出元组 T 中的第一个元素的类型。

type Head<T extends unknown[]> = T extends [] ? never : T[0];

Head 首先判断是否满足 T extends [],如果满足,说明 T 是只包含空数组的单元素集,返回 never;否则,说明 T 不是空数组单元素集,可能有第一个元素,返回 T[0]

为什么条件泛型里只有 extends 关键字,而没有 equals 关键字或 == 运算符?我的领悟是:在集合运算中,只有包含运算才能产生「是」或「否」的结果,而集合的其他运算:交集、并集、补集、映射,他们的运算结果都是另一个集合。在集合的语境下,A 包含于 B,意味着 A 是 B 的子集;切换到类型语境下,即 A 是 B 的子类,也就是 A 继承自 B。

如何判断两个类型完全相同?只需判断它们互相包含(互相继承)。

Head 进行一些测试:

type Head_Test1 = Head<[1, number]>;    // => 1
type Head_Test2 = Head<string[]>;       // => string
type Head_Test3 = Head<unknown>;        // ts error
type Head_Test4 = Head<[]>;             // => never

前三条测试的结果是符合直觉的。第四条,当我们把包含空数组的单元素集传递给 Head,得到的结果是 never 类型,表示空集,也没有什么问题。

让我们再看一眼第二条测试:请问空数组是不是 string[] 集合的元素?当然是了。那么,在真实世界的 Head 映射中,空数组被映射为了什么元素?

集合论中,映射的前提是,映射规则对源集合内的所有元素都生效。我们应该如何理解 Head

我的理解是:TS 中存在一个写不出来(JS 中没有)的 never 对象,而能写出来never 类型表示包含 never 对象的单元素集。同时,TS 中任何能写出来的类型都隐式包含了 never 对象,这使得任何类型与 never 类型求并集得到的都是自身,从而使本来是单元素集的 never 类型在概念上变成了空集。

从这个角度理解 Head<string[]> 就说得通了:string[] 集合中的空数组元素被映射为了 never,而其他元素被映射为了 string;因为 string | never 依然是 string,所以最终返回 string

泛型 Tail

第三条泛型 Tail 提取元组 T尾项(即除去第一项后剩余的那些项)的类型。

type Tail<T extends unknown[]> = T extends [] ? never : T extends [unknown, ...infer R] ? R : T;

有点复杂。

让我们先来看一个简易版本:

type SimpleTail<T extends unknown[]> = T extends [unknown, ...infer R] ? R : never;

SimpleTail 在形式上和 JS 代码很像:使用剩余参数运算符,把元组中除去第一项的剩余部分提取出来。简单的测试也没有问题:

type SimpleTail_Test1 = SimpleTail<[]>;                    // => never
type SimpleTail_Test2 = SimpleTail<[1, 2, string]>;        // => [2, string]
type SimpleTail_Test3 = SimpleTail<[1, 2, ...string[]]>;   // => [2, ...string[]]

但是,如果用 string[] 测试一下,得到了 never。这就不太对劲了:

type SimpleTail_Test3 = SimpleTail<string[]>;              // => never

在真实世界中,string[] 集合中的几乎所有元素(除空数组对象外),取尾项操作都是有意义的。事实上,如果我们举一些例子进行归纳的话,一定可以得出结论:对 string[] 取尾项的结果string[]。但是,根据 SimpleTail 的实现:string[] 又确实不是 [unknown, ...unknown[]] 的子集,我们只能返回 never

再来看正式版本的 Tail

type Tail<T extends unknown[]> = T extends [] ? never : T extends [unknown, ...infer R] ? R : T;
  1. 分支 1:如果 T 是空数组单元素集的子集,我们可以断定:T 只能是空数组单元素集或 never,此时返回 never
  2. 分支 2:如果 T 是「由所有「拥有第一项的数组」组成的集合」的子集,我们可以断定:T 不可能包含空数组元素,此时用类似 SimpleTail 中的形式提取出尾项类型。
  3. 分支 3:如果上述两者都不满足,我们直接返回 T

传入 string[] 测试一下,通过分支 3,得到了预期的类型:string[]

type Tail_Test4 = Tail<string[]>;                           // => string[]

你真的笃定吗?如果 T 即不满足分支 1 也不满足分支 2,就一定是类似于 string[]number[] 这种纯粹的数组类型,而不可能是其他类型吗?

让我们归纳一下数组(包括元组)类型的写法:(我们不关心数组项的具体类型,全部用 unknown 代替)

  1. 空数组:[]
  2. 纯粹的数组:unknown[]
  3. 元组:[unknownA, unknownB, unknownC]
  4. 带剩余项的元组:[unknownA, unknownB, ...unknownC[]]

经过归纳,我们发现定义数组类型的写法只有上面这 4 种,没有其他的了!能够写出来的数组类型,能够算出来(其他泛型返回)的数组类型,最后都能归纳到其中。这 4 种写法就是 TS 处理数组类型的边界,换言之 TS 无法产生「无法用这 4 种写法组合出来」的数组类型。

正是基于对以上知识的理解,我们确信只有第 2 种写法(纯粹的数组类型)才能走到分支 3。才能够放心地在分支 3 里返回直接 T

注意,这里还有一个陷阱。考虑传入并集的情况:

type Tail_Test5 = Tail<[] | string[] | [1, 2, 3]>;          // string[] | [2, 3]

根据集合论,并集的映射,应由组成并集的每个单个集合,分别映射之后,再对多个结果集取并集,作为最终的结果。

Tail 没有令我们失望,它正确地返回了预期的类型。但这是有条件的,泛型中的分支条件必须满足分发条件类型的约束:即条件必须是泛型参数直接 extends 某个类型(形如 T extends SOMETYPE),如果我们把 Tail 实现中的第一个条件 T extends [] 换成 Length<T> extends 0,分发条件类型的约束失效,命题「T 只可能是这 4 种写法之一」不复存在,—— 大厦由此坍塌。

type BrokenTail<T extends unknown[]> = Length<T> extends 0 ? never : T extends [unknown, ...infer R] ? R : T;

type BrokenTail_Test6 = BrokenTail<[] | [1, 3] | string[]>;  // => [] | [3] | string[]

你是否已经体会到泛型编程的某种笨拙之处?集合映射的规则(即泛型的语义)是基于集合内元素的,但泛型的实现者必须基于集合本身的运算来回答「映射之后是什么集合」的问题。这需要从真实世界的角度切实地归纳总结,才能保障映射的正确性最小性

转换函数的类型

目前,柯里化转换函数 curry 的类型定义如下:接收一个任意函数,返回 unknown

type Curry = (toCurry: Function) => unknown;

declare const curry: Curry;

我们要把 unknown 换成一个更精巧的类型,这样用户在使用 curry 返回的结果(即柯里化函数)时,就能够获得正确的类型提示了。

显然,这个「更精巧的类型」具体是什么,取决于调用 curry 时传入的那个函数。我们使用泛型约束把传入函数的参数 P 和返回类型 R 提取出来:

type Curry = <P extends unknown[], R>(toCurry: (...args: P) => R) => Curried<P, R>;

然后,将 PR 传递 Curried 泛型,作为柯里化函数的类型(即前述的「更精巧的类型」)。

注意,Curry 不是泛型映射,只是一个具有泛型约束的函数类型。

泛型 CurriedV1

CurriedV1Curried 泛型的第一版实现,它支持最简单的柯里化(每次只消费一个参数)。

type CurriedV1<P extends unknown[], R> = P extends [] ? R : (arg: Head<P>) => CurriedV1<Tail<P>, R>;

泛型是可以递归调用的,CurriedV1 就是这样,当它每次递归地调用自己,元组 P 的规模就减一,直到其变为空数组,结束递归。

测试一下,很完美:

type CurriedV1_Test1 = CurriedV1<[1, 2, 3], 0>; // => (arg: 1) => (arg: 2) => (arg: 3) => 0

你可能会问:如果传入一个无限(未知)长度的数组类型,比如 number[],TS 会不会陷入死循环?让我们试一试:

type CurriedV1_Test2 = CurriedV1<number[], 0>; // => (arg: number) => ...

TS 没有报错或陷入死循环,而是仍然映射出了一种可以无穷调用下去的函数类型。所以,我们可以得出结论:递归时逐渐缩减规模并不是泛型递归的必要条件。

事实上,泛型的某种惰性机制允许我们去为诸如 JS 中的循环引用对象返回自身的函数定义类型:

type Foo<T> = { foo: Foo<T> };
declare const foo: Foo<number>;
foo.foo.foo.foo.foo.foo;            // => 属性 foo 可以无限取下去

type Bar<T extends () => number> = () => Bar<T>;
declare const bar: Bar<() => 1>;
bar()()()()();                      // => 函数 bar 可以无限调用下去

讲到这里,其实大部分「从集合视角看泛型」的领悟已经陈述完毕了。接下来,我会加快一些速度,把更高级的柯里化的类型实现讲完。

CurriedV2:允许不定项参数

如果柯里化函数可以接收不定项参数(形如 curried(1)(2,3)(4)),就会更易用一些。我的实现是:

type Prepend<E, T extends unknown[]> = [E, ...T];

type Drop<N extends number, P extends unknown[], T extends unknown[] = []> =
    Length<T> extends N ? P : Drop<N, Tail<P>, Prepend<unknown, T>>;

type PartialTuple<T extends unknown[]> = Partial<T> & unknown[];

type CurriedV2<P extends unknown[], R> =
    Length<P> extends 0
    ? R
    : <T extends PartialTuple<P>>(...args: T) => CurriedV2<Drop<Length<T>, P>, R>;

type Curry = <P extends unknown[], R>(fn: (...args: P) => R) => CurriedV2<P, R>;

declare const curry: Curry;
declare const toCurry: (a1: 1, a2: 2, a3: 3, a4: 4) => 0;

const curried = curry(toCurry);
curried(1, 2)(3, 4); // => 0

泛型 Prepend

泛型 Prepend 在元组类型前加上一项。

type Prepend<E, T extends unknown[]> = [E, ...T];

type Prepend_Test1 = Prepend<1, [2]>;                   // ==> [1, 2]
type Prepend_Test2 = Prepend<1, [2, ...3[]]>;           // ==> [1, 2, ...3[]]
type Prepend_Test3 = Prepend<1 | 2, 3[]>;               // ==> [1 | 2, ...3[]]

注意,Prepend 不是条件类型,自然不满足分发条件类型,所以 Prepend_Test3[1 | 2, ...3[]] 而不是 [1, ...3[]] | [2, ...3[]]。如果你想要得到后者,可以将 Prepend 的实现放在条件类型内,如下所示:

type DistributedPrepend<E extends unknown, T extends> = E extends unknown ? [E, ...T] : never;

type DistributedPrepend_Test1 = DistributedPrepend<1 | 2, 3[]>;    // ==> [1, ...3[]] | [2, ...3[]]

本文后续讨论假设所有传入的类型都是不分散的(即非并集的形式),也不再讨论分发条件类型的问题。

泛型 Drop

泛型 Drop 负责从元组中删掉头部的 N 个元素。Drop 也是递归的,每次递归删掉一个元素,同时放置一个 unknown 到元组 T 中。当元组 T 的长度与 N 相等时,说明已经删掉了足够多的元素,把剩下的元素返回即可。

type Drop<N extends number, P extends unknown[], T extends unknown[] = []> =
    Length<T> extends N ? P : Drop<N, Tail<P>, Prepend<unknown, T>>;

简单地测试,没有问题。

type Drop_Test1 = Drop<2, [1, 2, 3, 4]>;    // => [3, 4]
type Drop_Test2 = Drop<5, [1, 2, 3, 4]>;    // => never
type Drop_Test3 = Drop<5, [1, 2, ...3[]]>;  // => 3[]

Drop 的关键在于,使用了一个空数组,也就是第三个泛型参数 T 来进行计数。

有趣的是,类似的机制可以用来实现整数的加减法:

type FromLength<N extends number, P extends unknown[] = []> = 
    Length<P> extends N ? P : FromLength<N, Prepend<unknown, P>>;

type Add<A extends number, B extends number, Res extends unknown[] = FromLength<A>, Count extends unknown[] = []> = 
    Length<Count> extends B ? Length<Res> : Add<A, B, Prepend<unknown, Res>, Prepend<unknown, Count>>;

type Sub<A extends number, B extends number, Res extends unknown[] = [], Count extends unknown[] = FromLength<B>> = 
    Length<Count> extends A ? Length<Res> : Sub<A, B, Prepend<unknown, Res>, Prepend<unknown, Count>>;

type Eight = Add<3, 5>;     // => 8
type Four = Sub<9, 5>;      // => 4

泛型 PartialTuple

泛型 PartialTuple 的故事要从 TS 的官方泛型 Partial 开始讲。我们知道 Partial 泛型可以将一个对象类型的所有属性都变得可选。当它作用于数组时,会使数组的每一项变成可选,比如 Partial<[number, string]> 可以得到类似[number?, string?] 的类型。

我们期望 CurriedV2 支持不定项参数,因此需要从定项参数元组中抽取出「元组的前任意项」类型:比如定项参数是类型 [1, 2, 3],那么不定项参数可以是 [1][1, 2] 或者 [1, 2, 3]。然而,TS 目前的类型运算没办法实现「元组的前任意项」这样的映射规则,而 Partial 是最接近的实现(最小超集)。

为什么又需要 PartialTuple 呢?因为被 Partial 转换后的类型已经不再是元组了:诸如 lengthmap 等属性也成了可选属性,这使得形如 {0: 'Hello'} 这样的对象也在 Partial<[string]> 的集合内。PartialTuple 将这部分不属于元组的元素剔除在外。

type PartialTuple<T extends unknown[]> = Partial<T> & unknown[];

原文直接使用 Partial 而不报错,这是 TS 的一个 bug:对于 Partial 传入元组类型后,究竟还是不是元组,在不同的条件下判断不一致。我提交了 issue最简复现

泛型 CurriedV2

CurriedV2CurriedV1 在框架上有点类似:

type CurriedV1<P extends unknown[], R> = 
    P extends [] ? R : (arg: Head<P>) => CurriedV1<Tail<P>, R>;

type CurriedV2<P extends unknown[], R> =
    P extends [] ? R : <T extends PartialTuple<P>>(...args: T) => CurriedV2<Drop<Length<T>, P>, R>;

最重要的一点区别是,CurriedV2 为柯里化函数引入了泛型约束,这样每次调用时,就能动态提取出传入参数的数量,并据此计算此次调用应该返回的类型。

type CurriedV1_Test1 = CurriedV1<[1, 2, 3], 0>; // => (arg: 1) => (arg: 2) => (arg: 3) => 0

type CurriedV2_Test1 = CurriedV2<[1, 2, 3], 0>;
// => <T extends PartialTuple<[1, 2, 3]>>(...args: T) => CurriedV2<Drop<Length<T>, [1, 2, 3], []>, 0>

简单测试,我们发现 CurriedV2_Test1 无法直白给出柯里化函数的类型,因为每一步调用后得到类型,只有调用的时候才能(根据参数)确定。

CurriedV3:支持剩余参数

有些函数的参数分为两个部分:固定参数和剩余参数。比如这样的 toCurry:在前四个固定参数之后,你可以传入任意个类型为 5 的剩余参数:

declare const toCurry: (a1: 1, a2: 2, a3: 3, a4: 4, ...args: 5[]) => 0;

// 必须在最后一次调用时一次性传入所有剩余参数
curry(toCurry)(1, 2, 3)(4, 5, 5);

如果柯里化可以支持这种函数,无疑会更好:这就是 CurriedV3 的目标。我的实现是:

type CurriedV3<P extends unknown[], R> =
    P extends [unknown, ...unknown[]]
    ? <T extends PartialTuple<P>>(...args: T) => CurryV3<Drop<Length<T>, P>, R>
    : R;

type Curry = <P extends unknown[], R>(fn: (...args: P) => R) => CurriedV3<P, R>;

declare const curry: Curry;
declare const toCurry: (a1: 1, a2: 2, a3: 3, a4: 4, ...args: 5[]) => 0;

const curried = curry(toCurry);
const result = curried(1, 2, 3)(455);

CurriedV3CurriedV2 的区别仅仅在于递归结束条件不同:CurriedV3 通过判断满足 P extends [unknown, ...unknown[]] 推断出 P 仍然包含固定项,此时继续递归;不满足此条件说明 P 中只有剩余参数了,结束递归。

得益于严密的 Drop 以及背后的 Tail —— 它们妥善处理了纯粹数组和包含剩余项元组的情况 —— CurriedV3 的递归部分和 CurriedV2 是完全一致的。

type Drop_Test3 = Drop<5, [1, 2, ...3[]]>;  // => 3[]
type Tail_Test5 = Tail<1[]>;                // => 1[]

如果 DropTail 对上述较为边缘的处理不够完善(比如直接返回 never[]),CurriedV1CurriedV2 并不会受什么影响,但是 CurriedV3 的实现就没那么容易了。

CurriedV4: 支持占位符

柯里化中的占位符,能够帮助我们延迟传入参数的时机。比如:

// 普通的调用
curried(1, 2, 3)(4, 5);
// 占位符调用
curried(1, __, 3)(2, 4, 5);
// 甚至
curried(1, __, 3)(__, 4)(2, 5);

这就是 CurriedV4 的目标。我的实现是:

type Equal<X, Y> = X extends Y ? Y extends X ? true : false : false;

type Item<T extends unknown[]> = T extends (infer R)[] ? R : never;

type PlaceholderTuple<T extends unknown[], M extends unknown> = { [P in keyof T]?: T[P] | M } & unknown[];

type Reverse<T extends unknown[], R extends unknown[] = []> =
    Equal<Length<T>, number> extends true
    ? Item<T>[]
    : T extends [unknown, ...unknown[]]
    ? Reverse<Tail<T>, Prepend<Head<T>, R>>
    : R;

type Join<P extends unknown[], T extends unknown[]> = 
    P extends [unknown, ...unknown[]] ? Join<Tail<P>, Prepend<Head<P>, T>> : T;

type Concat<P extends unknown[], T extends unknown[]> = Join<Reverse<P>, T>;

type PlaceholderMatched<T extends unknown[], S extends unknown[], M extends unknown, R extends unknown[] = []> =
    T extends [unknown, ...unknown[]]
    ? PlaceholderMatched<Tail<T>, Tail<S>, M, Head<T> extends M ? Prepend<Head<S>, R> : R>
    : Reverse<R>;

type __ = '__';
type CurriedV4<P extends unknown[], R> =
    P extends [unknown, ...unknown[]]
    ? <T extends PlaceholderTuple<P, __>>(...args: T) =>
        CurriedV4<Concat<PlaceholderMatched<T, P, __>, Drop<Length<T>, P>>, R>
    : R;

type Curry = <P extends unknown[], R>(fn: (...args: P) => R) => CurriedV4<P, R>;

declare const curry: Curry;
declare const toCurry: (a1: 1, a2: 2, a3: 3, a4: 4, ...args: 5[]) => 0;
declare const __: __;

const curried = curry(toCurry);
curried(1, __, 3)(2, 4, 5, 5);          // => 0
curried(1, __, 3)(__, 4)(2);            // => 0

泛型 Equal

泛型 Equal 判断两个类型是不是完全相等(注意,仍然是集合运算,truefalse 表示包含布尔值的单元素集合)。

type Equal<X, Y> = X extends Y ? Y extends X ? true : false : false;

type Equal_Test1 = Equal<number, 1>;            // => false
type Equal_Test2 = Equal<number, number>;       // => true

泛型 Item

泛型 Item 从数组类型中提取出数组项的可能类型。

type Item<T extends unknown[]> = T extends (infer R)[] ? R : never;

type Item_Test1 = Item<string[]>; // => string
type Item_Test2 = Item<[string, ...1[]]>; // => string | 1

泛型 PlaceholderTuple

泛型 PlaceholderTuplePartialTuple 很类似,它不仅使元组的每一项变成可选,而且使每一项都可能是传入的类型 M

type PlaceholderTuple<T extends unknown[], M extends unknown> = { [P in keyof T]?: T[P] | M } & unknown[];

泛型 Reverse

泛型 Reverse 将元组头尾翻转。

type Reverse<T extends unknown[], R extends unknown[] = []> =
    Equal<Length<T>, number> extends true
    ? Item<T>[]
    : T extends [unknown, ...unknown[]]
    ? Reverse<Tail<T>, Prepend<Head<T>, R>>
    : R;

泛型 Reverse 值得稍作展开。先看核心部分(从 T extends 开始):接收数组类型 T,递归地调用自己,每次递归将 T 的头元素取下来,从头部推入 R 中。当 T 消耗殆尽,R 自然就是翻转后的数组。

对于固定长度的元组类型,这样做没问题。但是,如果想要翻转不固定长度的数组类型呢?

通过真实世界中的简单的归纳,我们知道 Reverse<string[]> 应该是 string[],映射仍然是完美的;对于 Reverse<[string, ...number[]]>,我们只能将其映射为 Array<string | number> —— 我们之前说过,泛型的返回时常比我们预期的类型要宽泛,这不可避免。

Reverse 实现的前两行(非核心部分),就是用来处理上述两种不固定长度数组类型的。

测试一下:

type Reverse_Test1 = Reverse<[1, 2, 3]>;                // => [3, 2, 1]
type Reverse_Test2 = Reverse<unknown[]>;                // => unknown[]
type Reverse_Test3 = Reverse<[string, ...number[]]>;    // => Array<string | number>

泛型 Join

泛型 Join 将两个元组类型「头对头连接起来」。注意,第一个参数必须是固定项的元组类型。

type Join<P extends unknown[], T extends unknown[]> = P extends [unknown, ...unknown[]] ? Join<Tail<P>, Prepend<Head<P>, T>> : T;

type Join_Test1 = Join<[1, 2], [3, 4]>;         // => [2, 1, 3, 4]
type Join_Test2 = Join<[1, 2], [3, ...4[]]>;    // => [2, 1, 3, ...4[]]
type Join_Test3 = Join<[1, ...2[]], [3, 4]>;    // => ts error

泛型 Concat

泛型 Concat 将两个元组类型顺序连接起来。同理,第一个参数也必须是固定项的元组类型。

type Concat<P extends unknown[], T extends unknown[]> = Join<Reverse<P>, T>;

type Concat_Test1 = Concat<[1, 2], [3, 4]>;         // => [1, 2, 3, 4]
type Concat_Test2 = Concat<[1, 2], [3, ...4[]]>;    // => [1, 2, 3, ...4[]]
type Concat_Test3 = Concat<[1, ...2[]], [3, 4]>;    // => ts error

泛型 PlaceholderMatched

泛型 PlaceholderMatched 将元组 T 中的类型为 M 的项找出来,然后从元组 S 中提取出对应位置的项,顺序存放在一个新的元组里 R,并最终返回。

type PlaceholderMatched<T extends unknown[], S extends unknown[], M extends unknown, R extends unknown[] = []> =
    T extends [unknown, ...unknown[]]
    ? PlaceholderMatched<Tail<T>, Tail<S>, M, Head<T> extends M ? Prepend<Head<S>, R> : R>
    : Reverse<R>;

有一点拗口。简单看一下测试就知道 PlaceholderMatched 的具体作用了:

type __ = '__';
type PlaceholderMatched_Test1 = PlaceholderMatched<[1, __, __, 4], [1, 2, 3, 4, 5], __>; // => [2, 3]

泛型 CurriedV4

最后来看柯里化后函数类型的完全体 CurriedV4

type __ = '__';

type CurriedV4<P extends unknown[], R> =
    P extends [unknown, ...unknown[]]
    ? <T extends PlaceholderTuple<P, __>>(...args: T) =>
        CurriedV4<Concat<PlaceholderMatched<T, P, __>, Drop<Length<T>, P>>, R>
    : R;

CurriedV4CurriedV3 的区别在递归部分。我们用 PlaceholderTuple<P, __> 约束柯里化函数的入参,这样调用者就可以传入占位符常量 __ 了。

单次递归中,我们将「被占位的元素」构成的元组类型提取出来(即 PlaceholderMatched<T, P, __>),然后与此次调用消耗参数后剩余的参数(即 Drop<Length<T>, P>>)连接起来,作为新的参数 P,传入下一次递归。

测试一下,完美。

type Curry = <P extends unknown[], R>(fn: (...args: P) => R) => CurriedV4<P, R>;

declare const curry: Curry;
declare const toCurry: (a1: 1, a2: 2, a3: 3, a4: 4, ...args: 5[]) => 0;
declare const __: __;

const curried = curry(toCurry);

curried(1, __, 3)(2, 4, 5, 5);          // => 0
// => CurriedV4<[1, 2, 3, 4, ...5[]], 0> => CurriedV4<[2, 4, ...5[]], 0>

curried(1, __, 3)(__, 4)(2);            // => 0
// => CurriedV4<[1, 2, 3, 4, ...5[]], 0> => CurriedV4<[2, 4, ...5[]], 0> => CurriedV4<[2, ...5[]], 0>

小结

虽然本文中,对集合的讨论主要集中在前半部分,但是促使我去思考的,其实是对后面几个更高级的场景的实践。我发现,把这些实践的领悟套用在最开始的几个简单泛型上进行陈述,似乎更加清晰。

原文中,一开始的基础泛型就不是很严密,比如 Head 泛型是这样的:

type Head<T extends any[]> = T extends [any, ...any[]] ? T[0] : never;

这导致 Head<string[]> 返回的是 never,明显与从集合视角看上去的情形不符。

原文的很多基础类型,都存在没有处理妥善的边缘用例,所以当问题越来越复杂之时,泛型实现就会越来越不可控。后来原作者开始引入 Cast 泛型,把推导到边缘的不准确的类型强行转换回来。

type Cast<X, Y> = X extends Y ? X : Y;

这引发了我的思考,这些基础泛型究竟应该实现成什么样?在反复的实践中,我发现凭借直觉写出来的代码往往不够准确,有那么一刻,我领悟到我缺少的其实是一种集合的视角;而一旦从集合的视角理解了泛型运算的实质,似有一种豁然开朗之感:什么能做,什么不能做,哪里可以妥协,哪里只能放弃,就都可以确定地分析出来了。

(完)