本文将进入到类型编程的深水区,将分为两部分,一部分先打牢基础,介绍一些概念,另一部分将剖析typescript 工具类型的原理。
首先需要明白几个概念,比如:条件类型,分布式条件类型以及infer
等。
条件类型 Conditional Types
条件类型的语法实际上就是三元表达式,如果你觉得这里的extends
不太好理解,可以简单理解为U中的属性在T中都有。
js
T extends U ? X : Y
我们先来看看条件类型常用的一个场景:泛型约束,实际上就是我们上面的例子:
js
function pickValue<T extends object, U extends keyof T>(
obj: T,
keys: U[]
): T[U][] {
return keys.map(key => obj[key])
}
这里的T extends object
与U extends keyof T
都是泛型约束,分别将T约束为对象类型 和将U约束为T的属性名的字面量联合类型。
通常使用泛型约束来使得泛型收窄 。也就是说,使用extends
就是为了让类型收窄。
同样的,就像三元表达式可以嵌套,条件类型也可以嵌套,如果你看过一些框架源码,也会发现其中存在着许多嵌套的条件类型,无他,条件类型可以将类型约束收拢到非常精确的范围内。
js
type TypeName<T> = T extends string
? "string"
: T extends number
? "number"
: T extends boolean
? "boolean"
: T extends undefined
? "undefined"
: T extends Function
? "function"
: "object";
分布式条件类型 Distributive Conditional Types
分布式条件类型的定义:对于属于裸类型参数的检查类型,条件类型会在实例化时期自动分发到联合类型上。
分布式条件类型是一个比较难以理解的概念,它在条件类型的基础上加了一些东西,先看下面的例子:
js
type TypeName<T> = T extends string
? 'string'
: T extends number
? 'number'
: T extends boolean
? 'boolean'
: T extends undefined
? 'undefined'
: T extends Function
? 'function'
: 'object'
// 使用上面的TypeName类型别名
// "string" | "function"
type T1 = TypeName<string | (() => void)>
// "string" | "object"
type T2 = TypeName<string | string[]>
发现在上面的例子里,条件类型的推导结果都是联合类型,并且结果就是类型参数被依次进行条件判断的结果。
再看另一个例子:
js
type Naked<T> = T extends boolean ? 'Y' : 'N'
/*
* 先分发到 Naked<number> | Naked<boolean>
* 然后到 "N" | "Y"
*/
type Distributed = Naked<number | boolean>
type Wrapped<T> = [T] extends [boolean] ? 'Y' : 'N'
/*
* 不会分发 直接是 [number | boolean] extends [boolean]
* 然后是"N"
*/
type NotDistributed = Wrapped<number | boolean>
现在来讲讲这几个概念了:
-
裸类型参数:没有额外被接口/类型别名包裹过的,就像被
[]
包裹后就不能再被称为裸类型参数。 -
实例化:条件类型的判断过程。
-
分发至联合类型的过程:
- 对于TypeName,它内部的类型参数T是没有被包裹过的,所以
TypeName<string | (() => void)>
会被分发为TypeName<string> | TypeName<(() => void)>
,然后再次进行判断,最后分发为"string" | "function"
。 - 抽象下具体过程:
TypeName( A | B | C ) extends T ? X : Y
相当于(A extends T ? X : Y) | (B extends T ? X : Y) | (C extends T ? X : Y)
。
- 对于TypeName,它内部的类型参数T是没有被包裹过的,所以
一句话概括:没有被额外包装的联合类型参数,在条件类型进行判定时会将联合类型分发,分别进行判断。
在上面的例子中,联合类型number | boolean
被包装成了[number | boolean]
,所以它不会分别进行判断。
infer
关键字
infer
是inference
的缩写,通常的使用方式是infer R
,R
表示 待推断的类型。
通常infer
不会被直接使用,而是被放置在底层工具类型中,需要在条件类型中使用。
工具类型ReturnType
就使用了infer
:
js
const foo = (): string => {
return '213'
}
type FooType = ReturnType<typeof foo>
infer
可以用前端开发中常见的一个例子类比,页面初始化时先显示占位交互,像Loading/骨架屏,在请求返回后再去渲染真实数据。
infer
也是这个思路,类型系统在获得足够的信息后,就能将infer后跟随的类型参数推导出来,最后返回这个推导结果。
js
const foo = (): string => {
return '213'
}
type FooType = ReturnType<typeof foo> // string
type ReturnType1<T> = T extends (...args: any[]) => infer R ? R : any
type FooType1 = ReturnType1<typeof foo> // string
类似的,借着这个思路我们还可以获得函数入参类型、类的构造函数入参类型、Promise内部的类型等。
infer
其实没有特别难消化的知识点,理解了 延迟推断 的概念,就能掌握它。
工具类型 Tool Type
这一部分包括TS内置工具类型 与社区的扩展工具类型。
内置工具类型
首先来看一下Partial
实现原理,如果你掌握了上面的知识就很好理解下面的逻辑:
js
type Partial<T> = {
[K in keyof T]?: T[k];
};
如果要实现Required
呢?
js
type Required<T> = {
[K in keyof T]-?: T[K];
};
-?
表示去除可选修饰符,这样所有的属性都是必选的了。
同样的道理,对于只读也有两个修饰符:readonly
和 -readonly
js
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
我们再来看看Pick
的实现原理:
js
type Pick1<T, U extends keyof T> = {
[P in U]: T[P]
}
interface IUser {
name: string
age: number
isMan: boolean
}
type IUser2 = Pick1<IUser, 'age' | 'isMan'>
IUser2的类型就是:type IUser2 = {
age: number;
isMan: boolean;
}
既然有了Pick
,那么自然要有Omit
,它和Pick
的写法非常像,但有一个问题要解决:我们要怎么表示T
中剔除了K
后的剩余字段?
这里我们又要引入一个知识点:never
类型,它表示永远不会出现的类型,通常被用来将收窄联合类型或是接口。
js
type Exclude<T, U> = T extends U ? never : T
// "3" | "4" | "5"
type LeftFields = Exclude<"1" | "2" | "3" | "4" | "5", "1" | "2">;
可以这么理解:"1"
在"1" | "2"
里面吗("1" extends "1"|"2" -> true
)?
在啊,那就剔除。"3"在吗?不在那就让它留下来。
这里实际上使用到了分布式条件类型的特性,假设Exclude接收T、U两个类型参数,T联合类型中的类型会依次与U类型进行判断,如果这个类型参数在U中,就剔除掉它(赋值为never
)。
那么 Omit
就可以这么写:
js
// U extends keyof any:把U约束为联合类型
type Omit1<T, U extends keyof any> = Pick<T, Exclude<keyof T, U>>
Exclude<keyof T, U>
从 T 中剔除 U 中的属性,那么就是剩下的属性了,然后利用 pick 获取剩余属性的类型。
再来看个常用的工具类型Record<Keys, Type>
,通常用于生成以联合类型为键名(Keys
),键值类型为Type
的新接口,比如:
js
type MyNav = "a" | "b" | "b";
interface INavWidgets {
widgets: string[];
title?: string;
keepAlive?: boolean;
}
const router: Record<MyNav, INavWidgets> = {
a: { widget: [''] },
b: { widget: [''] },
c: { widget: [''] },
}
这里有个技巧,利用K extends keyof any
来约束K必须为联合类型。
js
// K extends keyof any 约束K必须为联合类型
type Record1<K extends keyof any, T> = {
[P in K]: T;
};
在前面的infer
中,我们实现了用于获取函数返回值的ReturnType
:
js
type ReturnType1<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
其实把infer
换个位置,比如放到返回值处,它就变成了获取参数类型的Parameters
:
js
type Parameters1<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
还可以把它放在promise
中,这就是是PromiseType
:
js
const promise1 = (): Promise<string> => {
return new Promise(resolve => {
resolve('132')
})
}
type PromiseType1<T extends Promise<any>> = T extends Promise<infer R>
? R
: never
type ptype = PromiseType1<ReturnType<typeof promise1>> // string
这几个例子看下来,应该能发现类型编程的确没有特别高深晦涩的语法,它考验的是你对其中基础部分如索引 、映射 、条件类型的掌握程度。
社区工具类型
这一部分的工具类型大多来自于utility-types
递归的工具类型
前面写Partial
Readonly
Required
等几个对接口字段进行修饰的工具类型,但实际上都有局限性,如果接口中存在着嵌套呢?
js
type Partial<T> = {
[P in keyof T]?: T[P]; // T[P]是一个对象呢?
};
是否是对象类型的判断用T extends object
即可,那么如何遍历对象内部?实际上就是递归。
js
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}
interface IDeep {
name: string
obj: {
title: string
age: number
}
}
type IDeep1 = DeepPartial<IDeep>
const testDeep: IDeep1 = {
obj: {
title: '123'
}
}
返回满足条件的键名的工具类型
在有些场景下我们需要一个工具类型,它返回接口字段键名组成的联合类型,但是这个需要满足某些条件的键名, 比如:
- 可选/必选/只读/非只读的字段
- (非)对象/(非)函数/类型的字段
来看个最简单的函数类型字段FunctionTypeKeys
:
js
export type FunctTypeKeys<T extends object> = {
[K in keyof T]-?: T[K] extends Function ? K : never;
}[keyof T];
写的有些复杂,我们拆开来看:
js
interface IWithFuncKeys {
a: string;
b: number;
c: boolean;
d: () => void;
}
type WTFIsThis<T extends object> = {
[K in keyof T]-?: T[K] extends Function ? K : never;
};
type FnKyeType = WTFIsThis<IWithFuncKeys>;
很容易推导出FnKyeType
实际上就是:
js
type FnKyeType = {
a: never;
b: never;
c: never;
d: "d";
}
加上后面一部分:
js
// "d"
type UseIt2 = FnKyeType[keyof FnKyeType]
never
类型的值不会出现在联合类型中:
js
// string | number
type WithNever = string | never | number;
所以,{ [K in keyof T]: ... }[keyof T]
这个写法实际上返回键名组成的联合类型。
下面来看可选字段OptionalKeys
与必选字段RequiredKeys
,先来看个小例子:
js
type Option1 = {} extends { prop: number } ? "Y" : "N";
type Option2 = {} extends { prop?: number } ? "Y" : "N";
对于第一种情况,prop
是必须的,因此空对象{}
并不能继承自{ prop: number }
,而对于第二种情况下则可以。因此,可以使用这种思路来得到可选/必选的键名。
js
export type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K;
}[keyof T];
export type OptionalKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];
{} extends Pick<T, K>
,如果K
是可选字段,那么就留下(OptionalKeys,如果是RequiredKeys就剔除)。- 怎么剔除?当然是用
never
了。
总结
总结下我们上面书写的工具类型:
- 全量修饰接口:
Partial
Required
,以及对应的递归版本 - 裁剪接口:
Pick
Omit
- 基于infer:
ReturnType
ParamType
PromiseType
- 获取指定条件字段:
FunctionKeys
OptionalKeys
RequiredKeys
...
需要注意的是,有时候单个工具类型并不能满足你的要求,你可能需要多个工具类型协作。
其实,TypeScript 项目中的类型实际上是需要经过组织的,而不是这一个接口那一个接口,这里一个字段那里一个类型别名,更别说明明可以使用几个工具类型轻松得到的结果却自己重新写了一遍接口。
但很遗憾,要做到这一点实际上会耗费大量精力,并且对业务带来的实质提升是微乎其微的(长期业务倒是还好),毕竟页面不会因为你的类型声明严谨就访问量暴增。
所以,在开发中还需要在开发的效率和质量间做好平衡。