TS 的类型系统十分完备,本质上可以看做是一门图灵完备的编译期语言。而「类型体操 Type Gymnastics」指利用 TS 中的类型系统进行各类复杂操作甚至实现部分算法的技巧。
在基础篇中,将回顾 TS 类型系统的一些基本类型和行为。
never与{}
never表示不存在的类型,是所有类型的子类型。利用 never 在类型层面的“空”特性,可以实现类型的筛选和排除。
function foo(x: string | number) {
if (typeof x === 'string') return
if (typeof x === 'number') return
x // never(不可达分支)
}
但是,{}并非表示空类型,而是所有非null/undefined的类型。换言之,有下列类型结果:
type A = string extends {} ? true : false // true
type B = number extends {} ? true : false // true
type C = {} extends {} ? true : false // true
type D = null extends {} ? true : false // false
type E = undefined extends {} ? true : false // false
keyof:类型系统的「反射」
keyof经常被用于获取对象类中键的字面量联合类型,例如:
type T = {
name: string
age: number
}
// keyof T: 'name' | 'age'
keyof也可以被使用在类上,且只会返回公有属性的键的类型。
class User {
readonly name: string
private age: number
protected address: string
bio: string
}
// keyof User: "name" | "bio"
在其他类型上使用keyof,通常返回该类型索引签名和原型方法与属性的字面量。
keyof any[] // number | 'length' | 'toString' | 'push' | 'pop' | ...
keyof [string, number] // '0' | '1' | 'length' | 'push' | ...
keyof string // number | 'length' | 'charAt' | 'substring' | ...
keyof '字符串字面量' // 同 keyof string
这是因为,对于数组、字符串等类型,在 TS 里有如下的类型签名:
interface Array<T> {
[index: number]: T
length: number
push(...items: T[]): number
...
}
interface String {
readonly length: number
charAt(pos: number): string
substring(start: number, end?: number): string
...
}
遵循同样的规则,keyof any为string | number | symbol,因为任意键都可以是字符串、数字或 symbol 类型。
联合类型与交叉类型
联合类型|表示值可以是多种类型中的一种;交叉类型&表示值必须同时满足多个类型。这两个操作符是组合类型的基本方式。
type A = { key: string }
type B = { key: number }
A | B // { key: string } | { key: number }
A & B // never
条件类型
条件类型以类似三目表达式的形式,根据类型关系进行分支选择以决定最终的类型,能够实现类型系统中的if-else逻辑。
使用extends来实现条件类型,常用于类型过滤和类型分发。
T是裸类型参数时,T extends U会判断构成T的每一个子类型是否满足extends U子句。需要防止这种分发时,可以将T包裹在元组中。裸类型参数:指在泛型中未被包裹(即没有被如Array<T>、[T]、Promise<T>等类型修饰)的裸露的类型参数。// 类型过滤,从联合类型中提取字符串类型
type ExtractString<T> = T extends string ? T : never
type A = ExtractString<string | number> // string
// 类型分发,当 T 是联合类型时,条件类型会分配给每个子类型
type ToArray<T> = T extends any ? T[] : never
type B = ToArray<string | number> // string[] | number[]
// 防止分发,将 T 包裹在元组中,避免条件类型分配给每个子类型
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never
type C = ToArrayNonDist<string | number> // (string | number)[]
类型推断infer
如果说extends实现了类型系统中的条件分支,那么infer就相当于结构赋值。infer只能在extends的字句中使用,它声明一个待推断的变量类型,并让类型系统自动推导出具体的类型赋给它。
infer常用于提取函数、数组、Promise 等复杂类型的内部组成部分,实现模式匹配(如取得函数返回值类型或取得元组的首个元素的类型)和实现类型系统中的递归解构。
// 推断函数 T 返回值的类型是 R,从而提取 R 的类型
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never
// 推断数组 T 中的第一个元素的类型是 R,从而提取 R 的类型
type First<T> = T extends (infer R)[] ? R : never
// 推断 T 的类型是返回 R 的 Promise,从而提取 R 的类型
type UnwrapPromise<T> = T extends Promise<infer R> ? R : never
// 推断数组 T 的类型是 R 的数组,如果是则递归进行展开
type DeepFlatten<T> = T extends (infer R)[] ? DeepFlatten<R> : T
此外,一个语句中可以包含多次infer,用于分别提取多个类型。
// 推断函数 T 的参数类型是 R,返回值类型是 U,从而提取 R 和 U 的类型到一个元组中
type ExtractToTuple<T> = T extends (arg: infer R) => infer U ? [R, U] : never
type FuncParseInt = (arg: string) => number
type Result = ExtractToTuple<FuncParseInt> // [string, number]
type FuncToString = (arg: boolean | number) => string
type Result2 = ExtractToTuple<FuncToString> // [boolean | number, string]
映射类型
映射类型通过遍历键的联合类型,来生成新的类型,常用于对于已有的属性进行批量转换。
例如,批量修改属性:
type Readonly<T> = {
readonly [P in keyof T]: T[P]
}
type Partial<T> = {
[P in keyof T]?: T[P]
}
type Required<T> = {
[P in keyof T]-?: T[P]
}
或者,结合as子句,可以根据原有键名和属性值决定新键名,甚至返回never来删除属性。
type Getters<T> = {
[P in keyof T as `get${Capitalize<string & P>}`]: () => T[P]
}
type Person = { name: string; age: number }
type PersonGetters = Getters<Person>
// { getName: () => string; getAge: () => number }
type Filter<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K]
}
type Filtered = Filter<Person>
// { name: string }
元组类型
元组类型(Tuple)是数组类型的一种特化形式,它表示一个固定长度且每个位置元素类型已知的数组。与普通数组T[]不同,元组类型明确规定了每一索引处的类型,以及整体的长度。
// 普通数组:长度可变,所有元素类型相同
const arr: number[] = [1, 2, 3]
// 元组:长度固定,每个位置类型可不同
const tuple: [string, number, boolean] = ['hello', 42, true]
元组在 TypeScript 类型系统中扮演着“定长列表”的角色,是许多高级类型操作的基石。元组常用来表示坐标[x, y]、颜色[r, g, b]等结构固定的数据,且可以与下面的as const结合获得精确字面量类型。
与数组类型一样,通过T[number]可以获取元组中每个位置的类型,获取到的往往是一个联合类型。
const tuple = ['hello', 42, true]
type TupleElement = (typeof tuple)[number] // Type: string | number | boolean
const tuple = ['hello', 42, true] as const
type TupleElement = (typeof tuple)[number] // Type: "hello" | 42 | true
as与as const
前文使用到as子句。除了常见的断言类型之外,还能用于改变映射类型中的键。
as const是一个特别的语句,可以用来将类型推断缩窄到最明确的类型;当用在对象或数组上时,可以防止对象的属性或数组被修改。
const status = "success" // Type: string
const literalStatus = "success" as const // Type: "success"
const routes = ['home', 'about', 'contact'] as const
type Routes = (typeof routes)[number] // Type: "home" | "about" | "contact"
routes.push('blog') // Error
const person = { name: 'Owen', age: 30 } as const
type Person = typeof person // Type: { name: "Owen"; age: 30 }
person.name = 'John' // Error
常见的使用方式是在项目中替代enum,例如:
const status = {
SUCCESS: 'success',
FAIL: 'fail',
} as const
type Status = typeof status[keyof typeof status] // Type: "success" | "fail"
另一个常见的应用场景是在需要从值中提取类型,例如:
const routes = [
{ path: '/user/:id' },
{ path: '/biz/:app' }
] as const
type Route = typeof routes[number]
type Path = Route['path']
type ExtractParam<T> =
T extends `${string}:${infer P}` ? P : never
type Param = ExtractParam<Path> // Type: "id" | "app"
// 在函数中,相对于使用`string`,使用更窄的`Param`类型
function parseParam(param: Param) {
// ...
}
模板字面量类型
模板字面量类型(Template Literal Types)允许在类型层面拼接字符串。可以通过拼接的方式实现类型分发或映射。
type A = `${'a' | 'b'}_${1 | 2}`
// "a_1" | "a_2" | "b_1" | "b_2"
type Person = {
name: string
age: number
}
type PersonGetter = {
[P in keyof Person as `get${Capitalize<string & P>}`]: () => Person[P]
}
// { getName: () => string; getAge: () => number }
结合内置类型和infer又可分别实现字符串的转和子串的提取。
// 结合`infer`,实现路由参数的提取
type ParseRoute<Route extends string> =
Route extends `${infer _Start}:${infer Param}/${infer Rest}`
? Param | ParseRoute<Rest>
: Route extends `${infer _Start}:${infer Param}`
? Param
: never
type RouteParams = ParseRoute<"/user/:id/post/:postId"> // "id" | "postId"
递归类型
在类型定义中引用自身,称为递归类型。TypeScript 的类型系统具有图灵完备性,通过递归类型可以处理嵌套结构并模拟循环和递归算法。
常见的使用递归类型的场景是需要深度操作对象的时候,例如将深层对象中的每个属性进行修改。利用递归类型也能构建复杂的条件类型。
// 尾递归版本的字符串替换
type ReplaceAll<
S extends string,
From extends string,
To extends string,
Result extends string = ''
> = S extends `${infer Head}${From}${infer Tail}`
? ReplaceAll<Tail, From, To, `${Result}${Head}${To}`>
: `${Result}${S}`;
逆变与协变
协变与逆变是类型系统中的概念和性质,并非实际的语法。逆变与协变用来描述复杂类型(如函数、数组)在子类型关系中的变化方向。简单来说:
- 协变(Covariant):如果
A是B的子类型,那么Complex<A>是Complex<B>的子类型。 - 逆变(Contravariant):如果
A是B的子类型,那么Complex<B>是Complex<A>的子类型。 - 不变(Invariant):
Complex<A>和Complex<B>之间没有子类型关系。
在 TypeScript 中,对象属性、数组、返回类型是协变的,函数参数类型是逆变的(严格模式下,--strictFunctionTypes启用)。
// 协变示例:返回值类型
type GetNumber = () => number;
type GetAny = () => any;
let getNumber: GetNumber = () => 1;
let getAny: GetAny = getNumber; // 安全,因为 number 是 any 的子类型
// GetNumber extends GetAny 是 true
// 逆变示例:参数类型
type HandleString = (x: string) => void;
type HandleUnion = (x: string | number) => void;
let handleUnion: HandleUnion = (x) => console.log(x);
let handleString: HandleString = handleUnion; // 安全?在严格函数类型下是允许的
// 因为参数是逆变:string | number 是 string 的超类型,所以 HandleUnion 是 HandleString 的子类型
// HandleUnion extends HandleString 是 true
以上是具体的函数类型。下面假设函数类型带有参数,因而无法直接判断的情况。
type A = <T>() => T[]
type B = <T>() => Array<T>
let a: A = () => []
let b: B = () => []
a = b // ? 是否可以安全赋值
// B extends A 吗?
在存在泛型参数的情况下,TS 会进行全称量化,即将T假设为任意类型,即对于所有的类型 T 均必须满足 BB extends A的结果为true。
扩展运算符
TS 的类型系统中也可以使用扩展运算符...,帮助分发类型到具体的泛型参数。可以作用于数组中的任意位置。
借助于扩展运算符,可以很方便地获取数组的第一个或最后一个元素,并在类型层面实现诸如concat、push等方法。
// 返回第一个元素
type FirstEl<T> = T extends [infer S, ...any] ? S : never
// 合并数组
type Concat<T1 extends any[], T2 extends any> = [...T1, ...T2]
// 从数组中移除最后一个元素
type Pop<T> = T extends [...infer S, any] ? S : []
从只读数组中提取元素时使用扩展运算符进行分发,可以用于去除数组的readonly:
const arr = [1, 2, 'hello'] as const
type Writable<T extends readonly any[]> = T extends readonly [...infer U] ? [...U] : T
Writable<typeof arr> // [1, 2, 'hello']
Warm Up:实现Pick类型
Pick<T, K>类型用于从对象类型T中剔除K以外的属性,返回一个新的对象类型。
interface Todo {
title: string
description: string
completed: boolean
}
type TodoTitle = Pick<Todo, 'title'>
// { title: string }
type TodoLite = Pick<Todo, 'title' | 'completed'>
// { title: string; completed: boolean }
type ErrorCase = Pick<Todo, 'title' | 'invalid'>
// 错误
可以看到:
K类型应当是T中的属性名,通过为泛型参数K增加约束实现;- 返回的类型中,只有
K中指定的属性,因此需要使用[P in K]来映射K中的属性名; - 最终需要将
T中对应属性的类型映射到新的对象类型中。
type Pick<T, K extends keyof T> = {
[P in K]: T[P]
}