扩展 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 工具类,如果你有好的答案,请留言。

参考:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,496评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,407评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,632评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,180评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,198评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,165评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,052评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,910评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,324评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,542评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,711评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,424评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,017评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,668评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,823评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,722评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,611评论 2 353