TypeScript类型体操
最近看到了一道TS类型体操的面试题,要求实现日期格式化的FormatDate<DD-MM-YY>
,用于约束特殊时间格式的字符串。
感觉有点震撼,现在面试八股文都这么考了?震撼的同时还发现,我TM居然不会!
因此我决定用一点时间来学习一下TS的类型体操,并整理在这篇文章中。
参考
- TS官方文档
- Typescript 类型编程,从入门到念头通达写的很好,介绍了TS类型编程中的很多语法
- playground,TypeScript官网提供的练习场,可以用来调试类型
什么是类型编程
类型编程,是在变量的类型层面(而不是在变量的值层面)进行编程和计算。
具体来说,类型编程是在编译阶段对类型进行操作、组合、转换甚至是逻辑运算,从而实现更强大的类型检查和抽象能力。
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的类型系统当做是一门独立的编程语言,这一点非常重要。
就像我们学习一门普通的编程语言那样,学习类型编程,也可以从值、变量、运算符、表达式、判断、循环、内置数据结构等方面开始。
变量
类型编程里面,类型就是变量,内置类型就是值,自定义类型就是变量(严格来说应该是个常量,因为自定义类型声明之后不能重新赋值,不过不用这些细节)。
声明类型
在类型编程中,类型就是一等公民,诸如number
、string
这些基础类型,就是类型编程中的值。
对比下面两段代码,看看有什么体感
// 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类型编程容易混淆的地方,总而言之,只要是在类型声明(type
、interface
、enum
)中出现的字面量,都是类型
此外,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]
字符串
字符串在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'>
关于模式匹配的知识在后面会提到
由于对象的索引类型中也可以使用模版字符串,因此这个功能会非常强大
元组
在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
现在有一点对类型进行编程的感觉没?
扩展运算符
在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]
对象
索引类型(Index Types)在 TypeScript 中是用来描述那些能够通过索引获取值的类型,如数组和特定结构的对象。
索引类型
索引类型主要包含三个部分:索引签名、索引查询 、 索引访问
索引签名
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] // 得到了每个元素对应字符串字面量的联合类型
映射类型
在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
也可以直接获取旧类型属性值的联合类型,因此in
和keyof
经常结合在一起使用。
比如下面实现的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; }
条件判断
参考:
TS类型编程中没有if/else
语法,需要使用三元运算符Y extends X ? exp1 : exp2
来进行条件判断。
当 extends
左边的类型Y可以赋值给右边的类型X时(即X与Y兼容),得到的是exp1表达式的类型;否则得到的是exp2表达式的类型。
要计算这个表达式,就需要要知道赋值兼容的规则。
赋值兼容
在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
属性,因为函数中会用到这个属性。上面这两种调用方式都是合法的,我们可以说a
与b
兼容,等价于b
可以赋值给a
。
鸭子类型
上面这种代码也被称作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
字面量类型可以看做是对应字面量基础类型的子类型,比如下面这种1
是number
的子类型
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 类型兼容官方文档。
兼容判断是类型编程中条件类型的基础,建议掌握牢固。
模式匹配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'>
泛型
泛型就像是函数中的参数一样,根据使用时候传入的类型,来决定最终实际生效的类型
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,
}
分配条件类型
参考:官方文档
先来看看下面的代码,你发现令人困惑的地方了吗?
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"]
递归
类型时可以互相嵌套的,比如我们要定一个二叉树节点的类型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。
循环
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,递归终止;否则就在每轮递归中将首字符放在元素中,遍历剩余的字符。
接下来做什么
上面介绍了类型编程的大部分语法,但是要做到活学活用,还需要大量的练习
首先可以查看比较经典的源码实现,比如
- TS的内置工具类型,了解这些内置类型,可以帮我们更快的进行类型体操
- utility-types,一个类型体操工具库,提供了多种工具类型
然后可以尝试挑战type-challenges里面的题目,同时可以通过issue参与讨论。
最后,只要在业务中,可以可以按照自己的期望来实现对应的类型,我感觉类型体操这一关就达标了。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。