关于TS的类型系统

写了这么久的ts才了解到它的类型系统是图灵完备的,可以被拿来编程,网上称其为“类型体操(type gymnastics)”。这个词很有意思,虽然没有考据过来源,但是猜想这种编程和体操的性质很像——能锻炼技能但不实用。下文呢也是我接触学习后的一点总结,尽量从浅入深带大家了解一下。

TS由enhanced js + type system(类型系统)组成,其中编译后在.d.ts文件内的就是类型系统的代码了,所以对于类型系统编程完全可以在.d.ts内实现,只是需要给每条语句前加上declare关键字。为了简洁一点,本文的代码都是在.ts文件内的代码。

代码可以直接在typescript的playground里写,由于没有系统的IO接口,只能通过hover到类型名上看输出,如下图。

get output
get output

将会用到的TS类型语法

本节会聊一些重要的TS特性,对于之后的编程都是必须掌握的。知道的可以复习一下,不了解的可以学习一下,对于日常编程也是有帮助的。(一些太基础的不做解释,对应的特性都会标识出来)

本节的代码可以在这里查看。

泛型

泛型是可以自定义的局部类型变量

定义了泛型的函数

function foo<T>(input: T) {}
foo('a');

定义了泛型的类型别名(Type alias)

type Wrapper<AType> = { a: AType }; 
const obj: Wrapper<Date> = { a: new Date() };

元组与字符串模板类型

ts的字面量类型除了常见的数字、字符串、对象、布尔值外还有比较特殊的元组和字符串模板类型。

元组(Tuple)

ts的元组本质就是js里数组的子集,可以理解为指定长度以及每项类型的列表。下面Tuple类型限制了长度为3,每项为确定类型的元组。

type Tuple = [number, string, 123];
const tuple: Tuple = [1, 's', 123];

元组可以使用类似js解构的语法组合,下面Tuple1通过组合的方式复用了Tuple类型。

type Tuple1 = ['a', ...Tuple];
const tuple1: Tuple1 = ['a', 1, 's', 123];

PS: 如果想指定数组内某项类型也可以用该语法,下面StrLeadingArr类型的数组长度至少为1,且首项必须为字符串。

type StrLeadingArr = [string, ...number[]];
const slarr: StrLeadingArr = ['1', 1];

字符串模板(Template Literal Types)

字符串与js里有差不多的能力,作为类型的字符串模板可以使用泛型替代其中部分内容。
下面Hello<string>类型可以匹配hello开头的字符串,而Hello<'world'>类型只能匹配hello world

type Hello<Name extends string> = `hello ${Name}`;
const greeting: Hello<string> = 'hello type';
const greetingToWorld: Hello<'world'> = 'hello world';

类型推导与模式匹配

extends关键字与条件类型(Conditional Types)

extends关键字除了实现继承以及限制泛型下界外还会用于Conditional Types特性。该特性类似js里的三元表达式,通过extends关键字进行前后类型的匹配。

匹配是看extends前的类型是否是extends后类型的子集,比如下面类型'猫'不是类型'狗'的子集。

type 叫声<动物 extends '狗' | '猫'> = 动物 extends '狗' ? '汪' : '喵';
type 叫一声 = 叫声<'猫'>; // '喵'

下面类型123是类型number的子集

type 类型<值 extends number | string> = 值 extends number ? '数字' : '字符串';
type 值类型 = 类型<123>; // '数字'

使用infer搭配条件类型的extends

先说说类型推断(Type Inference),TS可以在很多地方自动判断类型而不需要用户主动声明,不如下面的变量声明和泛型函数

// 类型123
const typeinfer = 123;
// 类型number
let typeinfer1 = 123;

declare function typeinferfn<T>(input: T): T;
// 自动推断T为类型'hello'
typeinferfn('hello');

TS也提供了在条件类型里推断类型的能力,条件类型没有设计定义泛型的地方,而是新增了一个infer关键字。下面拿到函数类型返回类型的类型里用infer R替代了原来的类型约束,使TS去推断出R的类型使其能在?后的第一个分支内使用。

type MyReturnType<F extends Function> = F extends (...params: any) => infer R ? R : never;
// 如果条件类型支持泛型的话可能就会变成这样,如果对泛型函数(<T>() => T)比较熟可能这样会比较好理解
// type MyReturnType<F extends Function> = <R> F extends (...params: any) => R ? R : never;
type RType = MyReturnType<(input: string, input2: number) => { combined: string }>; // { combined: string } 

infer也能推导interface和type alias里的泛型类型

type GetPromiseResultType<P extends Promise<any>> = P extends Promise<infer R> ? R : never;
type RType1 = GetPromiseResultType<Promise<string | number>>; // string | number

infer也能推导元组内某个元素的类型

type GetTupleFirstType<Arr extends any[]> = Arr extends [infer First, ...any] ? First : never;
type TupleFirst = GetTupleFirstType<Tuple>; // number

type GetTupleSecondType<Arr extends any[]> = Arr extends [any, infer Second, ...any] ? Second : never;
type TupleSecond = GetTupleSecondType<Tuple>; // string

type GetTupleLastType<Arr extends any[]> = Arr extends [...any, infer Last] ? Last : never;
type TupleLast = GetTupleLastType<Tuple>; // 123

infer也能推导字符串模板内的泛型,这里有点像正则匹配了

type GetGreetingTo<Str extends string> = Str extends `hello ${infer Name}` ? Name : never;
type Target = GetGreetingTo<'hello world'>; // 'world'

使用类型系统的编程基础

OK,讲完了TS的一些基础语法,下面就过一遍编程语言教程都会讲的桥段。

本节的代码可以在这里查看。

定义变量

下面的代码都是基于TS类型系统,这节虽然定义的都是"类型"但都会称为"变量",在后文里同样会优先使用编程语言的术语。

字面量

支持的字面量有下面几种

type A11 = 1;
type A12 = 'abc';
type A13 = [1, 2, 'a'];
type A14 = { a: 'a' };
type A15 = true;

联合类型

联合类型组合成的变量值也会偶尔用到,这里提一下

type A21 = 1 | 2 | 'a';
type A22 = A21 | A13;

剩下的常用于比较判断

type A31 = number; // 所有数字的集合
type A32 = string; // 所有字符串的集合
type A33 = any;    // 所有值的集合
type A34 = never;  // 空集

局部变量

每个泛型都可以看作是局部变量,infer推断的类型也可以看作是局部变量,它们的作用域有所不同

type A41<T> = T extends infer LocalT ? (
    LocalT
) : never;

定义函数

泛型类型别名可以当作函数使用

type NumToString<Input extends number, Prefix extends string = 'input: '> = `${Prefix}${Input}`;
//   ^^^^^^^^^^^ ^^^^^         ^^^^^^                        ^^^^^^^^^^^    ^^^^^^^^^^^^^^^^^^^ 
//   name        param         param type                    default value  function body
type Str = NumToString<123>; // "input: 123"

由于TS语法限制,泛型在使用时必须指定确定类型,使得这里的函数不是一等公民

type FakeMap<Arr extends number[], Predict extends (p: number) => any> = any;
type RA1 = FakeMap<[1, 2, 3], NumToString>;
//                            ^^^^^^^^^^^
//                            Generic type 'NumToString' requires between 1 and 2 type arguments.(2707)

分支与判断

字面量与字面量的比较

在字面量之间使用extends相当于===,所以前后可以互换

type GetMessage<Type extends number> =
    Type extends 1 ? 'yes' : // Type === 1
    2 extends Type ? 'no' :  // 2 === Type
    Type extends 3 ? 'probably' : // Type === 3
    never;
type msg = GetMessage<2>; // "no"

字面量与集合比较

判断字面量是否属于某个集合,这里前后不能互换

type GetTypeName<Type> =
    Type extends number ? 'number' : // typeof Type === 'number'
    Type extends string ? 'string' : // ...
    Type extends Function ? 'function' :
    Type extends object ? 'object' :
    never;
type tt = GetTypeName<'abc'>; // "string"

传入联合类型

联合类型会分开处理,并将结果合并,其中never会被去除

type unionsMsg = GetMessage<1 | 3 | 4>; // 相当于 1 | 3 | 4 分别调用 GetMessage 返回 "yes" | "probably" | never
type unionsType = GetTypeName<3 | []>; // "number" | "object"

循环与递归

使用递归替代循环

TS的类型系统不支持循环,但是由于类型支持一定程度的递归,所以我们可以像函数式编程语言一样使用递归替代循环。下面的FillArray就是递归填充数组的例子。

思路是每次生成一个新数组返回,新数组第一个元素为填充元素,后面的元素通过递归传入数组

type FillArray<Arr extends any[], Value> =
    Arr['length'] extends 0 ?                    // 递归结束条件
        [] :                                     // 结束
        Arr extends [any, ...infer Rest] ?       // 模式匹配拿到除了第一项外的剩余项
            [Value, ...FillArray<Rest, Value>] : // 填充值和剩余项递归调用组合成结果
            [];                                  // 数组没有一个元素时会走这里
type arr = FillArray<[1, 2, 3], 'a'>;            // ["a", "a", "a"]

上面的例子如果不理解可以参照下面的TS版看

function fillArrayTS(array: any[], value: any): any[] {
    return array.length === 0 ?
        [] :
        [value, ...fillArrayTS(array.slice(1), value)];
}
const arr1 = fillArrayTS([1, 2, 3], 'a');
console.log('arr1', arr1);  // ["a", "a", "a"]

PS: FillArray函数里Arr extends [any, ...infer Rest]其实已经包含了数组内必须有一个元素的条件,所以Arr['length'] extends 0的判断其实不需要,可以简化一下

type FillArray1<Arr extends any[], Value> =
    Arr extends [any, ...infer Rest] ?
        [Value, ...FillArray<Rest, Value>] :
        [];

再来个例子

实现一个将字符串数组转为字符串的join函数,可以指定分隔符。

思路和上面差不多,停止条件不变,生成结果改为字符串模板,每次取第一项字符串加到结果里,然后判断当前数组长度是否等于1,如果不是则在当前项后面加上分隔符,然后剩下部分递归数组剩余项生成。代码如下

type JoinWIP<Arr extends string[], Separator extends string> =
    Arr extends [infer Head, ...infer Rest] ?
        `${Head}${Arr['length'] extends 1 ? '' : Separator}${Join<Rest, Separator>}` :
        '';

看起来没啥问题但是在编译器里提示HeadRest类型不对,不清楚为什么这里不能推断出正确类型,但是有个解决方法是通过另外定义个带类型约束泛型的类型别名去做类似type guard的工作。如下定义了约束stringstring[]type guard来使HeadRest成为需要的类型。

type ExtractStringArray<T extends string[]> = T;
type ExtractString<T extends string> = T;
type Join<Arr extends string[], Separator extends string> =
    Arr extends [ExtractString<infer Head>, ...ExtractStringArray<infer Rest>] ? // 这里使用了ExtractString和ExtractStringArray来确定推断出来的变量类型
        `${Head}${Arr['length'] extends 1 ? '' : Separator}${Join<Rest, Separator>}` :
        '';
type str1 = Join<['hello', 'world'], ','>; // "hello,world"

递归深度限制与尾递归优化

类型嵌套有深度限制,也就是说只能做有次数限制的循环递归,这点限制了很多复杂逻辑的实现。目前普通递归的次数不超过50次左右,比如对之前的Join函数传入长度超过48的数组就会报错

// 创建一个长度为L的数组
type MakeArrayByLen<L extends number, Result extends string[] = []> = Result['length'] extends L ? Result : MakeArrayByLen<L, ['0', ...Result]>;
type str2 = Join<MakeArrayByLen<49>, ','>;
//          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//          Type instantiation is excessively deep and possibly infinite.(2589)

ts4.5 新增了对尾递归的优化,如果递归符合尾递归的写法递归深度就支持更多的层次。

这里将上面的Join改成尾递归的写法,由于尾递归优化后程序不会保留函数上下文,需要在递归函数内手动传递,所以改造思路就是将结果字符串传给下一个递归函数,每个函数内将新的结果和传入的字符串拼接然后接着传。

type Join1<Arr extends string[], Separator extends string, Result extends string = ''> =
    Arr extends [ExtractString<infer Head>, ...ExtractStringArray<infer Rest>] ?
        Join1<Rest, Separator, `${Result}${Head}${Arr['length'] extends 1 ? '' : Separator}`> :
        Result;

优化后深度可以达到1000左右了

type str3 = Join1<MakeArrayByLen<999>, ','>; // "0,0,0,.....,0,0"

基础数学运算

类型系统内没有对数学运算的支持所以这块反而是最难实现的,如果要实现一套完善的浮点数运算就需要从二进制部分实现了,这里就简单提一下在特定条件下的数学运算。

判断正负

思路就是通过转字符串判断是否以负号开头(用高级特性实现基础特性也是没谁了)

type ToString<T extends number | string> = `${T}`;
type IsLessThanZero<N extends number> = ToString<N> extends `-${infer Num}` ? true : false;
type reless0 = IsLessThanZero<1>; // false
type reless01 = IsLessThanZero<-1>; // true

正整数加法

通过构建各自长度的元组,然后合并拿到结果的length,当然这么做还是会受到递归深度的影响

type Add<A extends number, B extends number> = [...MakeArrayByLen<A>, ...MakeArrayByLen<B>]['length'];
type resultadd = Add<10, 12>; // 22
// 由于递归深度限制只能支持0~999的加法
type resultadd1 = Add<999, 999>; // 1998

另外比较大小、减法、乘法、除法等等略过吧。。

实战类型体操

那么如何用上面的语言去大展身手呢?似乎没有什么实用场景,只能拿来写写算法了。目前有一个为这种语言量身定制的题库,挑一道题来练练手吧。

FlattenDepth

这道题比较常规也比较实用就拿来写写吧。

解题代码

如果是单层的Flatten会比较简单,只需要写一个之前提过的循环去遍历每一项判断是否是数组(item extends any[]),如果是通过组合元组的...语法与剩下项合并即可。

type Flatten<Arr extends any[]> = 
  Arr extends [infer Head, ...infer Rest] ?
    [...Head extends any[] ? Head : [Head], ...Flatten<Rest>] : 
    Arr;

这题需要支持多层flatten的话就需要不断去调用Flatten函数直到: 1. 达到指定的level 2. 数组中已经没有需要flatten的元素

其中1比较好判断,可以设置一个计数器(实用数组实现,数组长度即为当前计数)每次递归累加,然后判断是否等于level即可。2的话有多种方法,其中可以判断当前传入的数组是否arr extends number[],对于只会传入数字数组的这道题来说是ok的,不过如果想要更通用一点可以去比较Flatten后的数组是否和之前一样,这里我们用这种方法去实现题解。

type FlattenDepth<Arr extends any[], level extends number = 1, counter extends 0[] = []> = 
    counter['length'] extends level ?
      Arr :
      Flatten<Arr> extends Arr ?
        Arr :
        FlattenDepth<Flatten<Arr>, level, [...counter, 0]>;

虽然可以不用考虑性能,但是Flatten虽然调用了两次还是可以优化的,大家可以尝试一下。(提示用之前提过的局部变量)

优化后的代码

有更复杂的吗

有不少大佬实现了比较复杂的算法逻辑,可以参考中国象棋Lisp解释器BigInt加法等等,你也可以尝试一下。

结语

如果看完后对这门语言还是不太理解也不用担心,本质上类型体操也是为了加深对TS的理解,能有所启发就达到目的了。TS还有很多有用的类型特性等你探索,比如官方文档的Type Manipulation这章值得去看看。

最后提醒一下,请不要在生产代码中去写这些代码。

Reference