扩展 TypeScript 中的工具类型

前言

TypeScript 作为类型系统,官方提供了一些非常实用的工具类型来提高我们编写类型代码的速度,你可以在 TypeScript 手册中的 Utility Types 来查看,这些工具类型无疑是非常好用的,但是应用到项目中,你会发现工具类型覆盖面还是不够广,某些场景下还是会产生频繁使用的类型逻辑。

今天,我就结合自己的项目,把我常用的类型逻辑抽象成工具类型,来弥补官方工具类型的不足。

Writable

工具类型 Readonly<Type> 非常好用,可以把 Type 的属性全部设置成只读。

但很奇怪,Utility Types 里面的工具类型都有正反,比如 Partial 的反面 Required,官方没有给出 Readonly 的对立工具类型。

那我就自己写一个 Writable(你也可以换一个名字,比如像 NonNullable 一样,取个 NonReadonly) 工具类型:

/**
 * Make all properties in T Writable
 */

type Writable<T> = {
  -readonly [P in keyof T]: T[P];
}

在线演示链接🔗:Writable

Recordable

JavaScript 是动态的,这点在对象上表现的特别突出,例如我们可以随意删改对象的属性,面对复杂的浏览器环境,我们时不时就需要一个动态对象。

这时候我们可以通过 索引签名 来解决,但是奈何动态对象出现频率比较高,每次都索引太麻烦了。

interface Dictionary {
    [index: string]: number;
}


function fun(person: Dictionary) {}

此时我们可以抽取下逻辑,实现一个叫 Recordable 的工具类:

/**
 * Construct a type with a dynamic object
 */
type Recordable<T = any> = {
  [index: string]: T;
};

我们知道,对象的 key 除了是 string,还可以为 symbol、number,我们把它抽象下:

/**
 * Construct a type with a dynamic object
 */
type Recordable<T = any> = Record<keyof any, T>;

keyof any 说明:

type uni = keyof any; 等价于 type uni = string | number | symbol

在线演示链接🔗:Recordable

各种 Deep 工具类型

TypeScript 的工具类型,例如 Readonly、Partial、Required 它们有个小小缺点,就是不能深度操作类型,只能操作对象属性的第一层,我们当然可以使用 as const 来实现类似 Deep 功能,但是考虑到灵活可复用,我们最好还是有对应的工具类型。

Deep 工具类型的实现一个核心思想就是 递归,自己调用自己。

DeepReadonly

现在有一个 interface 如下:

interface Students {
    firstName: string;
    lastName: string;
    gender: string;
    age: number;
    address: {
        streetAddress: string;
        city: string;
        state: string;
        postalCode: string;
    };
}


type ReadonlyStudents = Readonly<Students>;

我们使用 Readonly 泛型工具类型,让这个 interface 编程只读:

type ReadonlyStudents = Readonly<Students>;

ReadonlyStudents 的内容现在为:

type ReadonlyStudents = {
    readonly firstName: string;
    readonly lastName: string;
    readonly gender: string;
    readonly age: number;
    readonly address: {
        streetAddress: string;
        city: string;
        state: string;
        postalCode: string;
    };
}

很明显,address 字段没有添加 Readonly,接下来我们实现 DeepReadonly:

/**
 * Make all deep properties in T readonly
 */
type DeepReadonly<T> = {
  readonly [P in keyof T]: DeepReadonly<T[P]>;
}

简单通过一个递归来实现,来验证下:

type DeepReadonlyStudents = DeepReadonly<Students>;

// 通过索引类型得到 address 的类型
type Address = DeepReadonlyStudents["address"];

// type Address = {
//     readonly streetAddress: string;
//     readonly city: string;
//     readonly state: string;
//     readonly postalCode: string;
// }

不仅如此,这个 DeepReadonly 对数组也是有效的:

interface Students {
    firstName: string;
    lastName: string;
    gender: string;
    age: number;
    address: {
        streetAddress: string;
        city: string;
        state: string;
        postalCode: string;
    };
    phoneNumbers: {
        type: string;
        number: string;
    }[];
}

type DeepReadonlyStudents = DeepReadonly<Students>;

type PhoneNumbers = DeepReadonlyStudents["phoneNumbers"][number]; 

type PhoneNumbers = {
//     readonly type: string;
//     readonly number: string;
// }

在线演示链接🔗:DeepReadonly 不支持方法版

对象里面的属性比较复杂除了基本类型、数组、还有一个常见的是方法,我们需要加个条件类型:

interface Students {
    firstName: string;
    lastName: string;
    gender: string;
    age: number;
    address: {
        streetAddress: string;
        city: string;
        state: string;
        postalCode: string;
    };
    phoneNumbers: {
        type: string;
        number: string;
    }[];
    getHeight: () => number
}


type DeepReadonly<T> = T extends Function ?  T : {
  readonly [P in keyof T]: DeepReadonly<T[P]>;
};

type DeepReadonlyStudents = DeepReadonly<Students>;

type GetHeight = DeepReadonlyStudents["getHeight"]; 

// type GetHeight = () => number

在线演示链接🔗:DeepReadonly 支持方法版本

迷惑的点:

一个好的递归,都应该有终止条件,但是你看上面的递归实现好像没有终止条件,难道不会死循环吗? 根据上面的运行答案发现其实不会,我们可以实践一个案例:比如 type DeepReadonlyStudents = DeepReadonly<string>; 我们给 DeepReadonly 一个 string 类型,得到的 DeepReadonlyStudents 也是一个 string 类型, 这说明 TypeScript 本身会为我们自动添加终止条件,本质是增加了一个条件类型,这个条件类型为 type TrueDeepReadonly<T> = T extends object ? DeepReadonly<T> : T; 它表示**非 object **类型时就返回本本身, **非 object ** 就是递归的终止条件。

PS:甚至我们不想让 TypeScript 帮我们处理数组类型,我们还可以引入条件泛型来自己处理,就像下面这样:

(infer R)[] 处理数组真是绝了。

在线体验:DeepReadonly<T> 自己处理数组版本

参考:

DeepPartial

搞明白了 DeepReadonly ,DeepPartial 基本随手就能写出来了,简单一个递归就行了:

type DeepPartial<T> = {
    [P in keyof T]?: DeepPartial<T[P]>;
};

DeepRequired

同上 DeepPartial 的逻辑,不同的是我们需要在 ?: 前面加个减号 -?: 代码如下:

type DeepRequired<T> = {
    [P in keyof T]-?: DeepRequired<T[P]>;
};

演示代码:

interface Students {
    firstName?: string;
    lastName?: string;
    gender?: string;
    age?: number;
    address?: {
        streetAddress?: string;
        city?: string;
        state?: string;
        postalCode?: string;
    };
    phoneNumbers?: {
        type?: string;
        number?: string;
    }[];
    getHeight?: () => number
}

type DeepRequired<T> = {
    [P in keyof T]-?: DeepRequired<T[P]>;
};

type DeepRequiredStudents = DeepRequired<Students>;

type Address = DeepRequiredStudents["address"];

在线链接🔗:DeepRequired

DeepKeyObjectOf

这个工具类比较实用,比如下面这样,根据路径获取值:

const person = {
  name: "John",
  age: 30,
  dog:{
    name: "Rex",
  }
}

function get<ObjectType>(object: ObjectType, path: string){
  const keys = path.split('.');
  let result = object;
  for (const key of keys) {
    result = result[key];
  }
  return result;
}

get(person, "dog.name") // Rex

为了路径更友好的提示,我们可以实现 DeepKeyObjectOf :

type DeepKeyObjectOf<T> = T extends Record<string, any>
  ? {
      [K in keyof T & (string | number)]: T[K] extends object ? `${K}` | `${K}.${DeepKeyObjectOf<T[K]>}` : `${K}`;
    }[keyof T & (string | number)]
  : never;

但是 DeepKeyObjectOf 有一个缺点,不对数组进行支持,因为 TypeScript 无法解决下标索引的问题,我们也就无法实现 DeepKeyOf 工具类,如果你有好的答案,请留言。

参考:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。