TypeScript类型体操

最近看到了一道TS类型体操的面试题,要求实现日期格式化的FormatDate<DD-MM-YY>,用于约束特殊时间格式的字符串。

感觉有点震撼,现在面试八股文都这么考了?震撼的同时还发现,我TM居然不会!

因此我决定用一点时间来学习一下TS的类型体操,并整理在这篇文章中。

<!--more-->

参考

1. 什么是类型编程

类型编程,是在变量的类型层面(而不是在变量的值层面)进行编程和计算。

具体来说,类型编程是在编译阶段对类型进行操作、组合、转换甚至是逻辑运算,从而实现更强大的类型检查和抽象能力。

TS的类型系统本身就可以看做是一个独立的编程语言,虽然这个语言并不是图灵完备的,但仍然具有相当强大的表达能力,比如实现下面功能

  • 条件判断,根据特定条件渲染不同的类型
  • 映射,根据现有类型生成新的类型
  • 递归,类型可以自我引用形成递归定义
  • 类型推导,自动推导出复杂类型表达式的类型

学习类型编程,主要是为了更高效地使用TS的类型系统

  • 根据已有类型,扩展新类型,避免编写重复的代码
  • 定制实现某些特殊的类型检测,增强JS

拿第一点为例,比如已经有一个类型A,现在需要再定义一个类型B,跟A拥有完全一样的属性名称和对应的类型,但属性都是可选的

type A = {
    x: number,
    y: string
}

你可能会手动将A的属性复制过来,然后挨个添加?

type B = {
    x?: number,
    y?: number
}

这种操作会增加很多工作量,且A的属性添加或删除之后,都需要修改B的定义。

实际上TS内置了Partial<A>,可以很轻松地达到这个需求,而无需担心后续A的变化。

type B = Partial<A>

TS内置了很多类似的工具,一种办法是死记硬背,将这些工具类型都记住;

另外一种方法就是学习类型编程的底层逻辑,即使不依靠内置的工具类型,我们也可以借助类型编程的语法,实现功能相同的类型。

根据编程的思维:如果我们可以遍历A的所有属性,只需要为每个属性添加一个?看起来就可以了

遍历类型的所有属性值,可以使用keyof运算符,然后再类型映射,就可以实现一个自定义的MyPartial

type MyPartial<T> = {
    [P in keyof T]?: T[P]
}

type B2 = MyPartial<A>

把TS的类型系统当做是一门独立的编程语言,这一点非常重要。

就像我们学习一门普通的编程语言那样,学习类型编程,也可以从值、变量、运算符、表达式、判断、循环、内置数据结构等方面开始。

2. 变量

类型编程里面,类型就是变量,内置类型就是值,自定义类型就是变量(严格来说应该是个常量,因为自定义类型声明之后不能重新赋值,不过不用这些细节)。

2.1. 声明类型

在类型编程中,类型就是一等公民,诸如numberstring这些基础类型,就是类型编程中的值。

对比下面两段代码,看看有什么体感

// js
const a = 10
// var、let也可以定义变量
// ts
type A = number
// interface、enum也可以定义类型

此外,JS的值也可以直接作为类型,这也称为字面量类型

type A = 1
const a: A = 2 // a只能为1
type Obj = { x: 1, b: 2 }

const o: Obj = { x: 3, b: 2 } // x报错

type Arr = [number, 1, string] // 元组中类型和字面量混用
const arr: Arr = [10, 2, '2'] // 第二个元素必须为字面量1

这种字面量和基础类型混用的情况,也是学习TS类型编程容易混淆的地方,总而言之,只要是在类型声明(typeinterfaceenum)中出现的字面量,都是类型

此外,TS还会尝试从某个JS值中推导类型,也可以通过typeof关键字手动获取某个值的类型。

const a = [1,"2",3]
type A = typeof a //  (string | number)[]

const obj = { x: 1, y: 'aaa' }
type Obj = typeof obj // {x: number; y:string}

可以通过as const将变量的类型收窄

const a = [1,"2",3] as const
type A = typeof a
// 等价于
type A = [1,"2",3]

2.2. 字符串

参考:字符串字面量类型 官方文档

字符串在JS中是值,在TS中也可以做字面量类型,用来进行约束。

type A = "hello" | "world"

表示类型为A的变量只能为hello或者world

TS中也支持模版字符串,好家伙!记得跟值区分一下。

type World = "world";
type Greeting = `hello ${World}`; // Greeting的类型是 "hello wrold"

上面这种写法不是很常见,更常见的是字符串的联合类型

type A = "a" | "b";
type B = `${A}_id`; // "a_id" | "b_id"

字符串可以使用模式匹配,这可以让字符串在类型编程中大放异彩,比如通过下面的类型我们可以提取出第一个字符

type First<T extends string> = T extends `${infer R}${infer Rest}` ? R : never
type F = First<'abc'>

关于模式匹配的知识在后面会提到

由于对象的索引类型中也可以使用模版字符串,因此这个功能会非常强大

3. 元组

在TS中声明数组类型非常简单,使用Array<T>或者T[]即可。

由于js非常灵活,一个数组实际上可以放不同类型的元素。

如果只是单纯希望数组中的元素即可以是string也可以是number,不限制顺序,则可以使用联合类型

type Arr = Array<string | number>

但如果是要严格按索引位置,限制数组中每个元素的类型,则可以使用元组

type Arr = [string, number] // 数组中的元素按顺序的类型
const a: Arr = ['1', 2] // 
const b: Arr = [2, '1'] // 会报错

元组类型限定了数组中元素的类型、顺序和长度。

元组就是类型编程中的数组,理解了这一点,下面这种就属于常规操作了。

可以通过索引值直接访问元组中第i个元素的类型

type A = Arr[0] // string

由于长度固定,甚至可以可以访问元素的长度

type Len = Arr['length'] // 2

现在有一点对类型进行编程的感觉没?

3.1. 扩展运算符

在js中,可以对数组使用扩展运算符,快速获取数组元素和剩余元素

const arr = [1, 2, 3]
const [a, ...b] = arr 
const arr2 = [...b, a, 4, 5]

在类型编程中,也可以使用扩展运算符,拆分数组或者合并数组

借助扩展运算法,可以实现很多数组的操作,比如下面的Concat类型

type Concat<T extends any[], U extends unknown[]> = [...T, ...U]
type A = Concat<[1, 2], [3, 4]>//[1,2,3,4]

4. 对象

索引类型(Index Types)在 TypeScript 中是用来描述那些能够通过索引获取值的类型,如数组和特定结构的对象。

4.1. 索引类型

索引类型主要包含三个部分:索引签名、索引查询 、 索引访问

索引签名

js可以动态地向对象上添加属性,同样地,如何不能确定某个类型上具体的属性,可以使用动态的索引签名

type A = {
  [key: string]: number
}

其中,key的类型必须符合PropertyKey类型,这个是一个内置工具类型,代表了有效的键的类型

type PropertyKey = string | number | symbol

比如有时候我们想使用使用索引签名,但是又想要限制key的取值范围,而不是任意的字符串,使用联合类型 + in

type Keys = 'option1' | 'option2'
type Flags = { 
  [K in Keys]: boolean  // 不能在多个索引类型中使用in
}

索引查询

keyof T, 索引类型查询操作符,动态获取类型T上面的所有属性名,得到的是该类型的所有成员名称的联合类型

type Obj = {
  x: number
  y: string
}
type K = keyof Obj // "x" | "y"

这样不需要手动将Obj类型上面的属性名单独拆出来

索引访问

T[K], 索引访问操作符,可以通过类似于js中访问某个对象的属性值p[key]的形式,来访问某个类型的属性类型,

interface Obj {
    x: number
}

type X = Obj['x']

一种特殊的索引类型是数组类型,数组类型默认有一个为number的索引签名

type List = string[]
type Elm = List[number] // string

可以直接将元组转成其全部成员的联合类型

const tuple = ['tesla', 'model 3', 'model X', 'model Y'] as const
typeof tuple[number] // 得到了每个元素对应字符串字面量的联合类型

4.2. 映射类型

在js中,我们可以通过映射从一个对象构造另一个对象,比如我们通过for...in将一个对象的所有值都扩大2倍

const o1 = {
    x: 1,
    y: 2
}
const o2 = {}
for (const key in o1) {
    o2[key] = o1[key] * 2
}

TS没有提供循环,但我们也可以通过映射类型的方式从一个旧类型中创建新的类型

type Keys = 'option1' | 'option2';
type Flags = { [K in Keys]: boolean };

由于通过keyof也可以直接获取旧类型属性值的联合类型,因此inkeyof经常结合在一起使用。

比如下面实现的Readonly,将一个类型的所有属性修饰为readonly

type Readonly<T> = {
    readonly [P in keyof T]: T[P];
}

或者是实现内置的Pick类型

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P]
}

P作为循环中当前的属性变量,可以通过as对其重新赋值新的键值,这个功能也被成为remapping

比如下面这个将属性名首字母都改成大写

type CapitalKey<T> = {
  [P in keyof T as (P extends string ? `${Capitalize<P>}` : P)]: T[P]
}

type Friend = {
  firstName: string
  lastName: string
}

type CFriend = CapitalKey<Friend>

比如要实现自定义的Omit,将键值强行设置为never,就达到了忽略的目的。

type MyOmit<T, K extends keyof T> = {
  [P in keyof T as (P extends K ? never : P)]: T[P]
}
interface Todo {
  title: string
  description: string
  completed: boolean
}
type T = MyOmit<Todo, 'description' | 'completed'> // { title: string; }

5. 条件判断

参考:

TS类型编程中没有if/else语法,需要使用三元运算符Y extends X ? exp1 : exp2来进行条件判断。

extends 左边的类型Y可以赋值给右边的类型X时(即X与Y兼容),得到的是exp1表达式的类型;否则得到的是exp2表达式的类型。

要计算这个表达式,就需要要知道赋值兼容的规则。

5.1. 赋值兼容

在js中可以对变量通过=号进行重新赋值

let a = 10
a = 20
a = "123" // 在js中是合法的,但是我们不希望这种事情发生,因此需要使用TS做类型检测

除了基础的类型复制,更常见下面这种代码:对于某个变量,我们只需要判断他上面有某些属性,至于是否有其他的属性,我们并不关心。

function greet(person) {
    console.log(`Hi ${person.name}`)
}

const a = { name: 'a' }
greet(a)
const b = { name: 'b', age: 10 }
greet(b)

对于greet函数,参数person要求有name属性,因为函数中会用到这个属性。上面这两种调用方式都是合法的,我们可以说ab兼容,等价于b可以赋值给a

5.2. 鸭子类型

上面这种代码也被称作JS动态编程中的鸭子类型

当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。

回到类型编程,要判断x是否与y兼容,关注的不是y的具体类型,而是y是否拥有x中的全部属性;如果是,那么y也是一只“鸭子”。

在TS中,如果要将y赋值给x,就需要判断x是否与y兼容。

interface Named {
    name: string
}
let x: Named
// y的类型是 { name: string; age: number; }
let y = { name: 'Alice', age: 10 }
x = y

与鸭子类型一样,TS中基本的兼容规则是:如果x要兼容y,那么y至少具有与x相同的属性。

鸭子类型在某种程度上跟面向对象的多态功能是类似的,因此父类型肯定是与子类型兼容的(子类型拥有父类型的全部能力,可以用子类型来赋值替代付类型)

class Parent {
  x: number
}

class Child extends Parent {
  y: string
}

let c = new Child()
let p = new Parent()

p = c

字面量类型可以看做是对应字面量基础类型的子类型,比如下面这种1number的子类型

let x: number
let y: 1 = 1 // 1是number的子类型

x = y // true

在JS中,通过mixins混合合并两个对象,返回一个包含多个功能的对象是很常见的代码复用技巧。因此对于交叉类型而言,也可以看做是鸭子类型

type A = {
  x: number
}

type B = A & {
  y: string
}

let a: A = {
  x: 1
}

let b: B = {
  x: 1,
  y: '2'
}

a = b

如果直接根据属性数量或者子类型来判断,那么联合类型的兼容性检测多多少少会有点迷惑性。

考虑一下下面的赋值操作,哪个会报错?

let a: 1 | 2 = 1

let b: 1 | 2 | 3 = 3

a = b
b = a

看起来b的属性更多,那么a肯定是兼容b的吗?非也。

联合类型中的类型属性,并不是对象类型的属性数量,继续用鸭子类型想一下,

  • 一个函数期望接收的是一个1|2|3类型的值,传入1|2的值,这个函数肯定是可以通过的,因为函数里面会处理值为1|2|3的全部情况;
  • 但如果一个函数期望接收的是一个1|2类型的值,传入的确实1|2|3的值,函数里面漏掉了3的处理,如果传入的值为3,那函数就会报错,就起不到类型检测的目的了

因此,,b是无法赋值给a的,但a是可以赋值给b的,也就是

a = b // 报错
b = a // 成功

关于TS中类型兼容性的规则,建议直接阅读Type Compatibility 类型兼容官方文档

兼容判断是类型编程中条件类型的基础,建议掌握牢固。

5.3. 模式匹配infer

模式匹配是类型编程中非常有用的功能。

简单来说,模式匹配是在 X extends Y ? expr1 : expr2中,Y可以使用一个特殊的关键字infer R占位,通过 extends 对类型参数做匹配。

如果匹配成功,就会将匹配结果保存到通过 infer 声明的局部类型变量R里面,这个R可以在后续的 expr1 中使用。

一个比较有用的例子是:在实际开发中,我们可能会定义一些数组字面量,为了偷懒我们直接让TS自己类型推导,没有单独定义每个元素的类型

const arr = [
    { x: 1, y: 'hello', f: true, },
    { x: 2, y: 'hell', f: false, }
]

后面定义了一些函数,这个函数需要接收数组的元素作为参数,这里就必须要限制参数的类型了

// 这里需要限制row的类型为arr的元素类型
function choose(row: any) {}
choose(arr[0])

这个时候怎么办?重新定义元素的类型是一种方案,但实际上我们也可以借助infer推断出arr的元素类型

type ArrayType<T> = T extends Array<infer R> ? R : unknown
function choose(row: ArrayType<typeof arr>) {
  // row.x //正常
}

内置的ReturnType也是通过类型推断来实现的。

type MyReturnType<T> = T extends (...args: any) => infer R ? R : unknown

元组也可以结合扩展运算符和类型推断,来获取某一部分的类型,比如下面的Pop类型,可以忽略掉元组的最后一个类型

type Pop<T extends any[]> = T extends [] ? [] : T extends [...infer R, infer _] ? R : unknown

字符串字面量也可以使用模式匹配,这也是很多类型体操热衷折腾的领域

type First<T extends string> = T extends `${infer R}${infer Rest}` ? R : never
type F = First<'abc'>

6. 泛型

泛型就像是函数中的参数一样,根据使用时候传入的类型,来决定最终实际生效的类型

type Obj<X, Y> = {
    x: X,
    y: Y
}

type A = Obj<string, number>
type B = Obj<number, number>

跟下面的函数调用是不是很像?通过泛型,可以复用相似的类型定义,节省代码量。

function obj(x, y) {
    return { x, y }
}
obj('1', 2)
obj(100, 200)

在JS中,我们需要借助TS对参数类型进行限制;同样,在TS中,我们也可以借助泛型约束对泛型进行约束

type Obj<X extends number> = {
    x: X,
}

type A = Obj<string> // 报错 string extends number是false
type B = Obj<1> // 正常

在TS中,如果某个泛型参数没有通过extends进行约束,就被称作naked type parameter,比如

interface Box<T> {
  value: T; // T可以传入任意类型
}

在实际使用中,通常会尽可能为类型参数添加适当的约束,以提高类型安全性和可读性。没有约束的泛型类型参数在下面的分配条件类型中会进一步讲到。

在JS中,函数的参数可以设置默认值,泛型这里也可以设置默认类型

type Obj<X = number> = {
    x: X,
}
// 约束与默认值同时使用
type Obj<X extends number = 1> = {
    x: X,
}

6.1. 分配条件类型

参考:官方文档

先来看看下面的代码,你发现令人困惑的地方了吗?

type A = 'a' | 'b' | 'c' extends 'a' ? true : false // false

type MyExclude<T, U> = T extends U ? never : T
type B = MyExclude<'a' | 'b' | 'c', 'a'>   // 'b' | 'c'

直接判断'a' | 'b' | 'c' extends 'a',根据前面的鸭子类型,我们知道这里返回的是false

那么为什么通过泛型,将两个类型传入泛型之后,得到的结果不是never,而是排除了'a'之后的'b' | 'c'呢。

这是因为在TS中,如果传入的泛型参数是联合类型,同时泛型会用在条件判断中,这种情况被称作分配条件类型

举个例子

type Demo<T, U> = T extends U ? X : Y

当传入的T是联合类型时

type An = Demo<A | B | C, U>

最终T extends U ? X : Y整个表达式被解析为

(A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)

借助这个特性,我们可以将联合类型按照需要进行筛选,这也是为什么上面的MyExclude会正常运行原因。

另外一个常见的用法是通过T extends any是将联合类型进行拆分

type Permutation<T> = T extends any ? [T] : never

type An = Permutation<'A' | 'B'> // ['A'] | ['B']

分配条件类型是TS的默认行为,

type ToArray<Type> = Type extends any ? Type[] : never;

type StrArrOrNumArr = ToArray<string | number>; // string[] | number[]

如果在某些情况下需要避免这种行为,可以借助元组类型,即将泛型放在[]

type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;

type ArrOfStrOrNum = ToArrayNonDist<string | number>; (string | number)[]

可以使用分配条件类型实现很多联合类型的骚操作,比如,来一个上难度的联合类型转元组

type Util<T> = T extends any ? (u: () => T) => any : false
type Last<U> = Util<U> extends (i: infer I) => any ? I extends () => infer R ? R : never : never;
type UnionToTuple<U> = [U] extends [never] ? [] : [Last<U>, ...UnionToTuple<Exclude<U, Last<U>>>];

type An = UnionToTuple<'a' | 'b' | 'c'> // ["c", "b", "a"]

6.2. 递归

类型时可以互相嵌套的,比如我们要定一个二叉树节点的类型TreeNode,其左右子节点也是该类型

type TreeNode<T> = {
    value: T
    left?: TreeNode<T>
    right?: TreeNode<T>
};

借助于类型嵌套,我们可以实现类似于递归的效果

比如我们要实现一个深度的Readonly

type A = {
  a: string,
  b: {
    c: number,
    d: {
      e: boolean
    }
  }
}

将上面类型A的所有键值(包含内部嵌套的c、d、e)都变成readonly类型的

如果是通过JS编程实现类似的功能,伪代码是

function deepReadonly(obj) {
    for (const key in obj) {
        if (isObject(obj[key])) {
            obj[key] = deepReadonly(obj[key])
        } else {
            obj[key] = obj[key]
        }
    }
}

TS类型定义实现

type IsObject<T> = T extends object ? true : false
type IsFunc<T> = T extends (...args: any[]) => any ? true : false
type isPlainObj<T> = IsFunc<T> extends true ? false : IsObject<T> extends true ? true : false

type DeepReadonly<T> = {
  readonly [K in keyof T]: isPlainObj<T[K]> extends false ? T[K] : DeepReadonly<T[K]>
}

跟写递归函数一样,先考虑递归的终止条件,当T是基础类型时,就直接返回原始类型,这里需要用到条件判断。

然后再按照逻辑调用递归的类型,通过映射类型,为对象类型添加readonly。

一种特殊的递归用法是在多层递归之间传递一个顶层的类型,这样可以实现类似于全局变量的功能

比如下面的这个Chainable类型,可以通过用户传入的key和value推断出最终的返回值类型

declare const a: Chainable
const result1 = a
  .option('foo', 123)
  .option('bar', { value: 'Hello World' })
  .option('name', 'type-challenges')
  .get()

Chainable具体的实现如下

type Chainable<T = {}> = {
  option: <K extends string, V >(key: K extends keyof T ? never : K, value: V) => Chainable<Omit<T, K> & Record<K, V>>,
  get: () => T
}

注意这个T = {}的默认值设置,在option返回值的时候,将T递归传入下一个返回值类型中,这样最终类型就包含了传入的所有key和value。

6.3. 循环

TS类型编程中并不支持循环,但可以通过递归实现类似于循环的操作

比如我们要讲一个字符串字面量拆成一个元组,可以使用循环

const str = 'abc'
const arr = []
for (let i = 0; i < str.length; ++i) {
    arr[i] = str[i]
}

实际上我们也可以使用递归

const str = 'abc'
function dfs(i) {
    if (i >= str.length) return []
    return [str[i]].concat(dfs(i + 1))
}
const arr = dfs(0)

TS并没有提供循环的语法,但我们可以借助递归实现与循环相同的功能

type StrToTuple<T extends string> = T extends `${infer F}${infer L}`
  ? [F, ...StrToTuple<L>]
  : [];

type t1 = StrToTuple<"foo"> // ["f", "o", "o"]

当T为空字符串时,extends条件判断为false,递归终止;否则就在每轮递归中将首字符放在元素中,遍历剩余的字符。

7. 接下来做什么

上面介绍了类型编程的大部分语法,但是要做到活学活用,还需要大量的练习

首先可以查看比较经典的源码实现,比如

  • TS的内置工具类型,了解这些内置类型,可以帮我们更快的进行类型体操
  • utility-types,一个类型体操工具库,提供了多种工具类型

然后可以尝试挑战type-challenges里面的题目,同时可以通过issue参与讨论。

最后,只要在业务中,可以可以按照自己的期望来实现对应的类型,我感觉类型体操这一关就达标了。