上一篇介绍了高级进阶类型中未使用泛型的部分,现在我们来有关泛型的高级类型
从对象中抽取一些属性的值,然后拼接成数组,可以这么写,
const userInfo = {
name: 'lin',
age: '18',
}
function getValues(userInfo: any, keys: string[]) {
return keys.map(key => userInfo[key])
}
// 抽取指定属性的值
console.log(getValues(userInfo, ['name','age'])) // ['lin', '18']
// 抽取obj中没有的属性:
console.log(getValues(userInfo, ['sex','outlook'])) // [undefined, undefined]
虽然 obj 中并不包含 sex 和 outlook 属性,但 TS 编译器并未报错
此时使用 TS 索引类型,对这种情况做类型约束,实现动态属性的检查。
理解索引类型,需先理解 keyof(索引查询)、T[K](索引访问) 和 extends (泛型约束)。
keyof 操作符可以用于获取某种类型的所有键,其返回类型是联合类型。
interface IPerson {
name: string;
age: number;
}
type Test = keyof IPerson; // 'name' | 'age'
上面的例子,Test 类型变成了一个字符串字面量。
T[K],表示接口 T 的属性 K 所代表的类型,
interface IPerson {
name: string;
age: number;
}
let type1: IPerson['name'] // string
let type2: IPerson['age'] // number
T extends U,表示泛型变量可以通过继承某个类型,获得某些属性,之前讲过,复习一下,
interface ILength {
length: number
}
function printLength<T extends ILength>(arg: T): T {
console.log(arg.length)
return arg
}
这样入参就一定要有 length 属性,比如 str、arr、obj 都可以, num 就不行。
const str = printLength('lin')
const arr = printLength([1,2,3])
const obj = printLength({ length: 10 })
const num = printLength(10) // 报错,Argument of type 'number' is not assignable to parameter of type 'ILength'
对索引类型的几个概念了解后,对 getValue 函数进行改造,实现对象上动态属性的检查。
1)改造前,
const userInfo = {
name: 'lin',
age: '18',
}
function getValues(userInfo: any, keys: string[]) {
return keys.map(key => userInfo[key])
}
● 定义泛型 T、K,用于约束 userInfo 和 keys
● 为 K 增加一个泛型约束,使 K 继承 userInfo 的所有属性的联合类型, 即K extends keyof T
2) 改造后,
function getValues<T, K extends keyof T>(userInfo: T, keys: K[]): T[K][] {
return keys.map(key => userInfo[key])
}
这样当我们指定不在对象里的属性时,就会报错
TS允许将一个类型映射成另外一个类型。
介绍映射类型之前,先介绍一下 in 操作符,用来对联合类型实现遍历。
type Person = "name" | "school" | "major"
type Obj = {
[p in Person]: string
}
Partial将T的所有属性映射为可选的,例如:
interface IPerson {
name: string
age: number
}
let p1: IPerson = {
name: 'lin',
age: 18
}
使用了 IPerson 接口,就一定要传 name 和 age 属性
使用 Partial 改造一下,就可以变成可选属性,
interface IPerson {
name: string
age: number
}
type IPartial = Partial<IPerson>
let p1: IPartial = {}
Partial 原理
Partial 的实现用到了 in 和 keyof
/**
* Make all properties in T optional
*/
type Partial<T> = {
[P in keyof T]?: T[P]
}
● [P in keyof T]遍历T上的所有属性
● ?:设置为属性为可选的
● T[P]设置类型为原来的类型
Readonly将T的所有属性映射为只读的,例如:
interface IPerson {
name: string
age: number
}
type IReadOnly = Readonly<IPerson>
let p1: IReadOnly = {
name: 'lin',
age: 18
}
Readonly 原理
和 Partial 几乎完全一样,
/**
* Make all properties in T readonly
*/
type Readonly<T> = {
readonly [P in keyof T]: T[P]
}
● [P in keyof T]遍历T上的所有属性
● readonly设置为属性为可选的
● T[P]设置类型为原来的类型
Pick用于抽取对象子集,挑选一组属性并组成一个新的类型,例如:
interface IPerson {
name: string
age: number
sex: string
}
type IPick = Pick<IPerson, 'name' | 'age'>
let p1: IPick = {
name: 'lin',
age: 18
}
这样就把 name 和 age 从 IPerson 中抽取出来。
Pick 原理
/**
* From T, pick a set of properties whose keys are in the union K
*/
type Pick<T, K extends keyof T> = {
[P in K]: T[P]
}
Pick映射类型有两个参数:
● 第一个参数T,表示要抽取的目标对象
● 第二个参数K,具有一个约束:K一定要来自T所有属性字面量的联合类型
上面三种映射类型官方称为同态,意思是只作用于 obj 属性而不会引入新的属性。
Record 是会创建新属性的非同态映射类型。
interface IPerson {
name: string
age: number
}
type IRecord = Record<string, IPerson>
let personMap: IRecord = {
person1: {
name: 'lin',
age: 18
},
person2: {
name: 'liu',
age: 25
}
}
Record 原理
/**
* Construct a type with a set of properties K of type T
*/
type Record<K extends keyof any, T> = {
[P in K]: T
}
Record 映射类型有两个参数:
● 第一个参数可以传入继承于 any 的任何值
● 第二个参数,作为新创建对象的值,被传入。
T extends U ? X : Y
//若类型 T 可被赋值给类型 U,那么结果类型就是 X 类型,否则就是 Y 类型
Exclude 和 Extract 的实现就用到了条件类型。
Exclude 意思是不包含,Exclude
type Test = Exclude<'a' | 'b' | 'c', 'a'>
Exclude 原理
/**
* Exclude from T those types that are assignable to U
*/
type Exclude<T, U> = T extends U ? never : T
● never表示一个不存在的类型
● never与其他类型的联合后,为其他类型
type Test = string | number | never
Extract
type Test = Extract<'key1' | 'key2', 'key1'>
Extract 原理
/**
* Extract from T those types that are assignable to U
*/
type Extract<T, U> = T extends U ? T : never
懂了 Exclude,也就懂了 Extract。
为了方便开发者使用, TypeScript 内置了一些常用的工具类型。
上文介绍的索引类型、映射类型和条件类型都是工具类型。
除了上文介绍的,再介绍一些常用的,毕竟工具函数遇到了去查就行,死记硬背就太枯燥了,熟能生巧,写多了自然就熟悉了。
Omit
interface IPerson {
name: string
age: number
}
type IOmit = Omit<IPerson, 'age'>
这样就剔除了 IPerson 上的 age 属性。
Omit 原理
/**
* Construct a type with the properties of T except for those in type K.
*/
type Omit<T, K extends keyof any> =
Pick<T, Exclude<keyof T, K>>
Pick用于挑选一组属性并组成一个新的类型,Omit 是剔除一些属性,留下剩余的,他们俩有点相反的感觉。
那么就可以用 Pick 和 Exclude 实现 Omit。
当然也可以不用 Pick 实现,
type Omit2<T, K extends keyof any> = {
[P in Exclude<keyof T, K>]: T[P]
}
NonNullable 用来过滤类型中的 null 及 undefined 类型。
type T0 = NonNullable<string | number | undefined>; // string | number
type T1 = NonNullable<string[] | null | undefined>; // string[]
NonNullable 原理
/**
* Exclude null and undefined from T
*/
type NonNullable<T> =
T extends null | undefined ? never : T
● never表示一个不存在的类型
● never与其他类型的联合后,为其他类型
Parameters 获取函数的参数类型,将每个参数类型放在一个元组中。
type T1 = Parameters<() => string> // []
type T2 = Parameters<(arg: string) => void> // [string]
type T3 = Parameters<(arg1: string, arg2: number) => void> // [arg1: string, arg2: number]
Parameters 原理
/**
* Obtain the parameters of a function type in a tuple
*/
type Parameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never
在条件类型语句中,可以用 infer 声明一个类型变量并且对它进行使用。
● Parameters首先约束参数T必须是个函数类型
● 判断T是否是函数类型,如果是则使用infer P暂时存一下函数的参数类型,后面的语句直接用 P 即可得到这个类型并返回,否则就返回never
ReturnType 获取函数的返回值类型。
type T0 = ReturnType<() => string> // string
type T1 = ReturnType<(s: string) => void> // void
ReturnType 原理
/**
* Obtain the return type of a function type
*/
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : any
懂了 Parameters,也就懂了 ReturnType,
● ReturnType首先约束参数T必须是个函数类型
● 判断T是否是函数类型,如果是则使用infer R暂时存一下函数的返回值类型,后面的语句直接用 R 即可得到这个类型并返回,否则就返回any
在本节中,我们熟悉了很多工具类型的作用和原理,其实已经在不知不觉中做了一些类型体操了
TypeScript 高级类型会根据类型参数求出新的类型,这个过程会涉及一系列的类型计算逻辑,这些类型计算逻辑就叫做类型体操。当然,这并不是一个正式的概念,只是社区的戏称,因为有的类型计算逻辑是比较复杂的。
想一想我们之前研究的这些工具类型,都是在对类型做计算返回新的类型啊。
Ts是一门图灵完备的编程语言,即类型的可编码化,可以通过代码逻辑生成指定的各种类型,基于这点,才会有各种类型体操。
个人觉得 TS 类型体操这种东西,我们这些搬砖人不必像刷算法一样刻意去训练,浅尝辄止,了解即可,更多可了解 这篇文章[10]。
当使用第三方库时,很多三方库不是用 TS 写的,我们需要引用它的声明文件,才能获得对应的代码补全、接口提示等功能。
比如,在 TS 中直接使用 Vue,就会报错,
const app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
这时,我们可以使用 declare 关键字来定义 Vue 的类型,简单写一个模拟一下,
interface VueOption {
el: string,
data: any
}
declare class Vue {
options: VueOption
constructor(options: VueOption)
}
const app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
这样就不会报错了,使用 declare 关键字,相当于告诉 TS 编译器,这个变量(Vue)的类型已经在其他地方定义了,你直接拿去用,别报错。
需要注意的是,declare class Vue 并没有真的定义一个类,只是定义了类 Vue 的类型,仅仅会用于编译时的检查,在编译结果中会被删除。它编译结果是:
const app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
通常我们会把声明语句放到一个单独的文件(Vue.d.ts)中,这就是声明文件,以 .d.ts 为后缀。
// src/Vue.d.ts
interface VueOption {
el: string,
data: any
}
declare class Vue {
options: VueOption
constructor(options: VueOption)
}
// src/index.ts
const app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
一般来说,ts 会解析项目中所有的 *.ts 文件,当然也包含以 .d.ts 结尾的文件。所以当我们将 Vue.d.ts 放到项目中时,其他所有 *.ts 文件就都可以获得 Vue 的类型定义了。
/path/to/project
├── src
| ├── index.ts
| └── Vue.d.ts
└── tsconfig.json
那么当我们使用三方库的时候,是不是所有的三方库都要写一大堆 decare 的文件呢?
答案是不一定,要看社区里有没有这个三方库的 TS 类型包(一般都有)。
社区使用 @types 统一管理第三方库的声明文件,是由 DefinitelyTyped[11] 这个组织统一管理的
比如安装 lodash 的类型包,
npm install @types/lodash -D
只需要安装了,就可以在 TS 里正常使用 lodash 了,别的啥也不用做。
当然,如果一个库本来就是 TS 写的,就不用担心类型文件的问题,比如 Vue3。
比如你以前写了一个请求小模块 myFetch,代码如下,
function myFetch(url, method, data) {
return fetch(url, {
body: data ? JSON.stringify(data) : '',
method
}).then(res => res.json())
}
myFetch.get = (url) => {
return myFetch(url, 'GET')
}
myFetch.post = (url, data) => {
return myFetch(url, 'POST', data)
}
export default myFetch
现在新项目用了 TS 了,要在新项目中继续用这个 myFetch,你有两种选择:
● 用 TS 重写 myFetch,新项目引重写的 myFetch
● 直接引 myFetch ,给它写声明文件
如果选择第二种方案,就可以这么做,
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
declare function myFetch<T = any>(url: string, method: HTTPMethod, data?: any): Promise<T>
declare namespace myFetch { // 使用 namespace 来声明对象下的属性和方法
const get: <T = any>(url: string) => Promise<T>
const post: <T = any>(url: string, data: any) => Promise<T>
}
比较麻烦的是需要配置才行,可以有两种选择,
创建一个 node_modules/@types/myFetch/index.d.ts
文件,存放 myFetch
模块的声明文件。这种方式不需要额外的配置,但是 node_modules
目录不稳定,代码也没有被保存到仓库中,无法回溯版本,有不小心被删除的风险,故不太建议用这种方案,一般只用作临时测试。
创建一个 types
目录,专门用来管理自己写的声明文件,将 myFetch
的声明文件放到 types/myFetch/index.d.ts
中。这种方式需要配置下 tsconfig.json
中的 paths
和 baseUrl
字段。
// tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"baseUrl": "./",
"paths": {
"*": ["types/*"]
}
}
}
感觉直接用 TS 重写比给老项目写声明文件更好,这样就不用专门维护类型模块了。