深入理解 TypeScript 的工具类型原理

本文将进入到类型编程的深水区,将分为两部分,一部分先打牢基础,介绍一些概念,另一部分将剖析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 objectU 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)

一句话概括:没有被额外包装的联合类型参数,在条件类型进行判定时会将联合类型分发,分别进行判断

在上面的例子中,联合类型number | boolean被包装成了[number | boolean],所以它不会分别进行判断。

infer 关键字

inferinference的缩写,通常的使用方式是infer RR表示 待推断的类型

通常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 项目中的类型实际上是需要经过组织的,而不是这一个接口那一个接口,这里一个字段那里一个类型别名,更别说明明可以使用几个工具类型轻松得到的结果却自己重新写了一遍接口。

但很遗憾,要做到这一点实际上会耗费大量精力,并且对业务带来的实质提升是微乎其微的(长期业务倒是还好),毕竟页面不会因为你的类型声明严谨就访问量暴增。

所以,在开发中还需要在开发的效率和质量间做好平衡。

相关推荐
Tiffany_Ho4 小时前
【TypeScript】知识点梳理(三)
前端·typescript
看到请催我学习9 小时前
如何实现两个标签页之间的通信
javascript·css·typescript·node.js·html5
天涯学馆15 小时前
Deno与Secure TypeScript:安全的后端开发
前端·typescript·deno
applebomb18 小时前
【2024】uniapp 接入声网音频RTC【H5+Android】Unibest模板下Vue3+Typescript
typescript·uniapp·rtc·声网·unibest·agora
读心悦1 天前
TS 中类型的继承
typescript
读心悦1 天前
在 TS 的 class 中,如何防止外部实例化
typescript
Small-K2 天前
前端框架中@路径别名原理和配置
前端·webpack·typescript·前端框架·vite
宏辉2 天前
【TypeScript】异步编程
前端·javascript·typescript
LJ小番茄2 天前
TS(type,属性修饰符,抽象类,interface)一次性全部总结
前端·javascript·vue.js·typescript
It'sMyGo3 天前
Javascript数组研究03_手写实现_fill_filter_find_findIndex_findLast_findLastIndex
前端·javascript·typescript