TypeScript Type-Level Programming

TypeScript 有着足够强大(?)的类型系统,我们可以用它做类型层面的编程。首先来看如何在类型层面表达编程的几个要素:数据(广义地指可供编写者操作的所有实体)、操作变换数据的原语和控制流(广义,包括命令式的loop以及所谓函数式的recursion等)。

数据及其基本变换

TypeScript 中有 N 个原生类型:来自 JS 的 number, boolean, string, null, undefined, ...,以及 TS 中独特的 literal type(string, number, boolean literal 如 'foo', 1, true 可作为类型,其值域只包含自己,即 let a: 1 这个变量中只能存放 1 ),这些类型都可以在类型编程中作为基本数据对象,供我们操作。在此之上,我们可以借助 TS 的类型系统构造两种复合对象:“list” 和 “dict”。

我们把 TS 中所有的 object types (如 { a: number, b: string})定义为 dict。除了这种“literal dict”之外,可以用 TS 中的 Mapped Types 从一个 dict 得到另一个 dict:

{[K in keyof T]: /* do sth. to K or T[K] */}

Mapped Types 可以看作对原 dict 的 "values"作 map 变换(保留 keys 不变)得到的一个新的 dict

我们把 TS 中的 Union Type 定义为 list。要得到一个 list,除了最简单的“literal union type”之外,还有如下几种构造方式:

type T = { a: number, b: string }
keyof T  // 'a' | 'b',类比 dict.keys()
T[keyof T]  // number | string,对一个 dict 进行“lookup with unioned key”得到 Union Type,类比 dict.values()

结合 Mapped Types 和 Lookup Types,我们可以得到一个普适的对 list 进行 map的方法:

{[K in KS]: /* do sth. to K */}[KS]

KS 是任意的一个 list,这样我们就能得到一个对 KS 的每个元素做变换后的 list。问题是,怎么对每个元素做变换呢?

控制流

最显而易见的是 Conditional Types,它可以用来实现分支结构。如:

let v: (() => void) extends Function ? true : false

上面 v 的类型true

注意这里引入了extends用于判断子类型关系,可以作为谓词判断某一陈述的真假。

结合 conditional typing 与 never的特性(never是关于|运算的幺元,即a | never等于a),我们可以对 list 做 filter 操作:

type T = { a: number, b: string }
let numbers: {[K in keyof T]: T[K] extends number ? T[K] : never}[keyof T]  // numbers: { a: number | never } 即 { a: number }

在此基础上应用 lookup 即可实现对 dict 的 filter 操作。

如果说判断子类型关系相当于<=(I mean, in type lattice),那么我们还希望它能够判断=的关系。由a ≤ b /\ b ≤ a => a = b我们可知,只要能判断<=并能够进行逻辑与运算,就能判断相等关系。可惜的是 TS 并没有在这种“type condition”中为我们提供“与”逻辑,所以我们用嵌套的条件判断来表示“与逻辑”:

type Func = Function
let v: Function extends Func ? (Func extends Function ? true : false) : false  // v7: true

“非”、“或”逻辑同理,只需调整层次结构和分支顺序,我们就能实现任意的布尔逻辑了。

上面的这个判断相等的 pattern 很好用,我们可以把它放进一个函数……等等,函数?

Generic Type Declaration!

把上面的这种分支逻辑,放进一个带有类型参数的类型声明(别名)里,即可作为一个 type-level function,在需要时使用:

type Equal<T, U> = T extends U ? (U extends T ? true : false) : false
let v: Equal<Func, Function>  // v: true

有了函数,很自然地想到递归。不过目前 TypeScript 只支持 recursive mapped types,也就是说我们的“递归函数”只能返回 dict。作为一个有点牵强的例子,想象这样一个情景:有一种 object type T,它的 field 要么也是 T 类型的 object,要么是 Promise<U>;我们有一个函数将这种 object 里所有的 Promise 变成其结果(等待所有 Promise resolve ,记录结果并替换),那么如何描述这个函数的类型?

type Unwrapped<T> = { [K in keyof T]: T extends Promise<infer U> ? U : Unwrapped<T> }
type Unwrap<T> = (wrapped: T) => Unwrapped<T>

这里我们的 Unwrapped除了使用类型参数之外还使用了条件类型里的infer语法来引入新的 local binding,算是某种意义上的“模式匹配”。在普通的编程语言里,我们对 ADT 的值作模式匹配;而在我们的 type-level programming 里,我们对有着“代数类型类型”的类型作模式匹配。ADT 有 data constructor,它们其实就是普通的函数;而我们的“ATT”当然也对应地有 type constructor,比如 Promise就是一个把T这个类型变换成Promise<T>这个类型的“类型函数”。在我们的类型世界中,“代数类型类型”是除了 primitives 和 list, dict 之外构造数据的第三种方法。

我们可以尝试用这个代数类型来实现经典的自然数:

type Zero = void
type Succ<N> = { pred: N }
type Nat<N = Zero> = Zero | Succ<N>
type One = Succ<Zero>
type Two = Succ<One>

type IsNat<T> = T extends Nat<infer N> ? true : false
type ZeroIsNat = IsNat<Zero> // true
type OneIsNat = IsNat<One>  // true
type NumberIsNat = IsNat<number>  // false
type IsNatIsNat = IsNat<IsNat>  // error! IsNat cannot be used like this!

Succ 并不一定要有上述代码所示的结构,只是因为 TS 采用的是 structural typing,所以我们只要保证不同等级的 Succ 有不同的结构即可。用 pattern match 我们可以构造一个谓词函数,用来判断一个类型是否属于我们定义的代数类型。

有意思的是最后一句,我们将 IsNat 传给 IsNat,期望的答案是 false,即 IsNat 作为一个函数,不属于代数类型 Nat;而 TypeScript 编译器告诉我们

Generic type 'IsNat' requires 1 type argument(s).

这就是我们常说的,“函数不是一等公民”。

- THE END -

编辑于 2019-01-06

文章被以下专栏收录