TypeScript 的另外一面:类型编程

做者:穹心javascript

前言

做为前端开发的趋势之一,TypeScript 正在为愈来愈多的开发者所喜好,从大的方面来讲,几乎九成的框架与工具库都以其写就(或者就是相似的类型方案,如 Flow);而从小的方面来讲,即便是写个配置文件(如 vite 的配置文件)或者小脚本(感谢 ts-node),TypeScript 也是一大助力。同样事物不可能作到每一个人都喜欢,如 nodemon 的做者 Remy Sharp 就曾表示本身历来没有使用过 TS(见 #1565),在之后也不会去学习 TS,这多是由于语言习惯的问题。而一般阻碍新人上手 TypeScript 的还有另一座大山:学习成本高。前端

在学习 TypeScript 的开始阶段,不少同窗对它是又爱又恨的,离不开它的类型提示和工程能力,却又常常为类型错误困扰,最后不得不用个 any 了事,这样的状况多了,TypeScript 就慢慢写成了 AnyScript...java

这个问题的罪魁祸首其实就是部分同窗在开始学习 TypeScript 时,要么是被逼上梁山,在一片空白的状况下接手 TS 项目,要么是不知道如何学习,那么良心的官方文档不看,看了几篇相关文章就以为本身会了,最后遇到问题仍是一头雾水。node

这篇文章就是为了解决这后者的问题,尝试专一于 TypeScript 的类型编程部分(TS 还有几个部分?请看下面的解释),从最基础的泛型开始,到索引、映射、条件等类型,再到 is、in、infer 等关键字,最后是压轴的工具类型。打开你的 IDE,跟着笔者一节节的敲完代码,帮助你的 TypeScript 水平迈上新的台阶。react

须要注意的是,本文并不是 TypeScript 入门文章,并不适用于对 TypeScript 暂时没有任何经验的同窗。若是你仍处于新手期,笔者在这里推荐 xcatliu 的 TypeScript 入门教程 以及 官方文档,从我我的的经验来看,你能够在初期阅读入门教程,并在感到困惑时前往官方文档对应部分查阅。git

在完成 TypeScript 的基础入门后,欢迎再次回到本篇文章。程序员

TypeScript = 类型编程 + ES 提案

笔者一般将 TypeScript 划分红两个部分:github

  • 预实现的 ES 提案,如 装饰器、 可选链?. 、空值合并运算符??(和可选链一块儿在 TypeScript3.7 中引入)、类的私有成员 private 等。除了部分极端不稳定的语法(说的就是你,装饰器)之外,大部分的 TS 实现实际上就是将来的 ES 语法。chrome

    严谨的来讲,如今的 ES 版本装饰器和 TS 版本装饰器已是两个东西了,笔者先前在 走近 MidwayJS:初识 TS 装饰器与 IoC 机制 这篇文章中介绍了一些关于 TS 装饰器的历史,有兴趣的同窗不妨一读。typescript

    对于这一部分来讲,不管你先前是只有 JavaScript 这门语言的使用经验,仍是有过 Java、C#的使用经历,都能很是快速地上手,毕竟主要仍是语法糖为主嘛。固然,这也是实际开发中使用最多的部分,毕竟和另外一部分:类型编程比起来,仍是这一部分更接地气。

  • 类型编程,从一个简简单单的interface,到看起来挺高级的T extends SomeType ,再到各类不明觉厉的工具类型PartialRequired等,这些都属于类型编程的范畴。这一部分对代码实际的功能层面没有任何影响,即便你一行代码十个 any,遇到类型错误就 @ts-ignore (相似于@eslint-ignore,将会禁用掉下一行的类型检查),甚至直接开启 --transpileOnly (这一选项会禁用掉 TS 编译器的类型检查能力,仅编译代码,会得到更快的编译速度·),也不会影响你代码自己的逻辑。
    然而,这也就是类型编程一直不受到太多重视的缘由:相比于语法,它会带来许多额外的代码量(类型定义代码甚至可能超过业务代码量)等问题。并且实际业务中并不会须要多么苛刻的类型定义,一般只会对接口数据、应用状态流等进行定义,一般是底层框架类库才会须要大量的类型编程代码。
    若是说,上一部分让你写的代码更甜,那么这一部分,最重要的做用是让你的代码变得更优雅健壮(是的,优雅和健壮并不冲突)。若是你所在的团队使用 Sentry 这一类监控平台,对于 JS 代码来讲最多见的错误就是Cannot read property 'xxx' of undefinedundefined is not a function这种(见top-10-javascript-errors),虽然即便是 TS 也不可能把这个错误直接彻底抹消,但也能解决十之八九了。

好了,作了这么多铺垫,是时候开始进入正题了,本文的章节分布以下,若是你已经有部分前置知识的基础(如泛型),能够直接跳过。

  • 类型编程的基础:泛型

  • 类型守卫与 is、in 关键字

  • 索引类型与映射类型

  • 条件类型、分布式条件类型

  • infer 关键字

  • 工具类型

  • TypeScript 4.x 新特性

泛型

之因此上来就放泛型,是由于在 TypeScript 的整个类型编程体系中,它是最基础的那部分,全部的进阶类型都基于它书写。就像编程时咱们不能没有变量,类型编程中的变量就是泛型。

假设咱们有这么一个函数:

function foo(args: unknown): unknown { ... }
复制代码
  • 若是它接收一个字符串,返回这个字符串的部分截取。

  • 若是接收一个数字,返回这个数字的 n 倍。

  • 若是接收一个对象,返回键值被更改过的对象(键名不变)。

上面这些场景有一个共同点,即函数的返回值与入参是同一类型.

若是在这里要得到精确地类型定义,应该怎么作?

  • unknown 替换为 string | number | object ?但这样表明的意思是这个函数接受任何值,其返回类型均可能是 string / number / object,虽然有了类型定义,但彻底称不上是精确。

别忘记咱们须要的是 入参与返回值类型相同 的效果。这个时候泛型就该登场了,咱们先用一个泛型收集参数的类型值,再将其做为返回值,就像这样:

function foo<T>(arg: T): T {
  return arg;
}
复制代码

这样在咱们使用 foo 函数时,编辑器就能实时根据咱们传入的参数肯定此函数的返回值了。就像编程时,程序中变量的值会在其运行时才被肯定,泛型的值(类型)也是在方法被调用、类被实例化等相似的执行过程实际发生时才会被肯定的。

泛型使得代码段的类型定义易于重用(好比后续又多了一种接收 boolean 返回 boolean 的函数实现),并提高了灵活性与严谨性。

另外,你可能曾经见过 Array<number> Map<string, ValueType> 这样的使用方式,一般咱们将上面例子中 T 这样的未赋值形式成为 类型参数变量 或者说 泛型类型,而将 Array<number> 这样已经实例化完毕的称为 实际类型参数 或者是 参数化类型

一般泛型只会使用单个字母。如 T U K V S等。个人推荐作法是在项目达到必定复杂度后,使用带有具体意义的泛型变量声明,如 BasicBusinessType 这种形式。

foo<string>("linbudu");
const [count, setCount] = useState<number>(1);
复制代码

上面的例子也能够不指定,由于 TS 会自动推导出泛型的实际类型,在部分 Lint 规则中,实际上也不推荐添加可以被自动推导出的类型值。

泛型在箭头函数下的书写:

const foo = <T>(arg: T) => arg;
复制代码

若是你在 TSX 文件中这么写,<T>可能会被识别为 JSX 标签,所以须要显式告知编译器:

const foo = <T extends SomeBasicType>(arg: T) => arg;
复制代码

除了用在函数中,泛型也能够在类中使用:

class Foo<T, U> {
  constructor(public arg1: T, public arg2: U) {}

  public method(): T {
    return this.arg1;
  }
}
复制代码

单独对于泛型的介绍就到这里(由于单纯的讲泛型实在没有什么好讲的),在接下来的进阶类型篇章中,咱们会讲解更多泛型的使用。

类型守卫、is in关键字

咱们来从相对简单直观的知识点:类型守卫 开始,由浅入深的了解基于泛型的类型编程。

假设有这么一个字段,它可能字符串也多是数字:

numOrStrProp: number | string;
复制代码

如今在使用时,你想将这个字段的联合类型缩小范围,好比精确到string,你可能会这么写:

export const isString = (arg: unknown): boolean => typeof arg === "string";
复制代码

看看这么写的效果:

function useIt(numOrStr: number | string) {
  if (isString(numOrStr)) {
    console.log(numOrStr.length);
  }
}
复制代码

image

看起来 isString 函数并无起到缩小类型范围的做用,参数依然是联合类型。这个时候就该使用 is 关键字了:

export const isString = (arg: unknown): arg is string =>
  typeof arg === "string";
复制代码

这个时候再去使用,就会发如今 isString(numOrStr)true后,numOrStr的类型就被缩小到了string。这只是以原始类型为成员的联合类型,咱们彻底能够扩展到各类场景上,先看一个简单的假值判断:

export type Falsy = false | "" | 0 | null | undefined;

export const isFalsy = (val: unknown): val is Falsy => !val;
复制代码

这应该是我平常用的最多的类型别名之一了,相似的,还有 isPrimitiveisFunction这样的类型守卫。

而使用 in 关键字,咱们能够进一步收窄类型(Type Narrowing),思考下面这个例子,要如何将 " A | B " 的联合类型缩小到"A"?

class A {
  public a() {}

  public useA() {
    return "A";
  }
}

class B {
  public b() {}

  public useB() {
    return "B";
  }
}
复制代码

首先联想下 for...in 循环,它遍历对象的属性名,而 in 关键字也是同样,它可以判断一个属性是否为对象所拥有:

function useIt(arg: A | B): void {
  'a' in arg ? arg.useA() : arg.useB();
}
复制代码

若是参数中存在a属性,因为A、B两个类型的交集并不包含a,因此这样能马上收窄类型判断到 A 身上。

因为A、B两个类型的交集并不包含 a 这个属性,因此这里的 in 判断会精确地将类型对应收窄到三元表达式的先后。即 A 或者 B。

再看一个使用字面量类型做为类型守卫的例子:

interface IBoy {
  name: "mike";
  gf: string;
}

interface IGirl {
  name: "sofia";
  bf: string;
}

function getLover(child: IBoy | IGirl): string {
  if (child.name === "mike") {
    return child.gf;
  } else {
    return child.bf;
  }
}
复制代码

关于字面量类型literal types,它是对类型的进一步限制,好比你的状态码只多是 0/1/2,那么你就能够写成 status: 0 | 1 | 2 的形式,而不是用一个 number 来表达。

字面量类型包括 字符串字面量数字字面量布尔值字面量,以及4.1版本引入的模板字面量类型(这个咱们会在后面展开讲解)。

  • 字符串字面量,常见如 mode: "dev" | "prod"

  • 布尔值字面量一般与其余字面量类型混用,如 open: true | "none" | "chrome"

这一类细碎的基础知识会被穿插在文中各个部分进行讲解,以此避免单独讲解时缺乏特定场景让相关概念显得过于单调。

基于字段区分接口

我在平常常常看到有同窗在问相似的问题:登陆与未登陆下的用户信息是彻底不一样的接口,或者是

以前有个小哥问过一个问题,我想不少用 TS 写接口的小伙伴可能都遇到过,即登陆与未登陆下的用户信息是彻底不一样的接口(或者是相似的,须要基于属性、字段来区分不一样接口),其实也可使用 in关键字 解决:

interface ILogInUserProps {
  isLogin: boolean;
  name: string;
}

interface IUnLoginUserProps {
  isLogin: boolean;
  from: string;
}

type UserProps = ILogInUserProps | IUnLoginUserProps;

function getUserInfo(user: ILogInUserProps | IUnLoginUserProps): string {
  return 'name' in user ? user.name : user.from;
}
复制代码

或者经过字面量类型:

interface ICommonUserProps {
  type: "common",
  accountLevel: string
}

interface IVIPUserProps {
  type: "vip";
  vipLevel: string;
}

type UserProps = ICommonUserProps | IVIPUserProps;

function getUserInfo(user: ICommonUserProps | IVIPUserProps): string {
  return user.type === "common" ? user.accountLevel : user.vipLevel;
}
复制代码

一样的思路,还可使用instanceof来进行实例的类型守卫,建议聪明的你动手尝试下。

索引类型与映射类型

索引类型

在阅读这一部分前,你须要作好思惟转变的准备,须要真正认识到 类型编程实际也是编程,由于从这里开始,咱们就将真正将泛型做为变量进行各类花式操做了。

就像你写业务代码的时候经常会遍历一个对象,而在类型编程中咱们也会常常遍历一个接口。所以,你彻底能够将一部分编程思路复用过来。首先实现一个简单的函数,它返回一个对象的某个键值:

// 假设key是obj键名
function pickSingleValue(obj, key) {
  return obj[key];
}
复制代码

要为其进行类型定义的话,有哪些须要定义的地方?

  • 参数obj

  • 参数key

  • 返回值

这三样之间存在着必定关联:

  • key必然是 obj 中的键值名之一,且必定为 string 类型(一般咱们只会使用字符串做为对象键名)

  • 返回的值必定是 obj 中的键值

所以咱们初步获得这样的结果:

function pickSingleValue<T>(obj: T, key: keyof T) {
  return obj[key];
}
复制代码

keyof索引类型查询 的语法, 它会返回后面跟着的类型参数的键值组成的字面量联合类型,举个例子:

interface foo {
  a: number;
  b: string;
}

type A = keyof foo; // "a" | "b"
复制代码

是否是就像 Object.keys() 同样?区别就在于它返回的是联合类型。

联合类型 Union Type 一般使用 | 语法,表明多个可能的取值,实际上在最开始咱们就已经使用过了。联合类型最主要的使用场景仍是 条件类型 部分,这在后面会有一个完整的章节来进行讲解。

还少了返回值,若是你此前没有接触过此类语法,应该会卡住,咱们先联想下for...in语法,遍历对象时咱们可能会这么写:

const fooObj = { a: 1, b: "1" };

for (const key in fooObj) {
  console.log(key);
  console.log(fooObj[key]);
}
复制代码

和上面的写法同样,咱们拿到了 key,就能拿到对应的 value,那么 value 的类型就更简单了:

function pickSingleValue<T>(obj: T, key: keyof T): T[keyof T] {
  return obj[key];
}
复制代码

这一部分可能很差一步到位理解,解释下:

interface T {
 a: number;
 b: string;
}

type TKeys = keyof T; // "a" | "b"

type PropAType = T["a"]; // number
复制代码

你用键名能够取出对象上的键值,天然也就能够取出接口上的键值(也就是类型)啦~

但这种写法很明显有能够改进的地方:keyof出现了两次,以及泛型 T 其实应该被限制为对象类型。对于第一点,就像咱们平时编程会作的那样:用一个变量把多处出现的存起来,记得,在类型编程里,泛型就是变量

function pickSingleValue<T extends object, U extends keyof T>(
  obj: T,
  key: U
): T[U] {
  return obj[key];
}
复制代码

这里又出现了新东西 extends... 它是啥?你能够暂时把 T extends object 理解为T 被限制为对象类型U extends keyof T理解为 泛型 U 必然是泛型 T 的键名组成的联合类型(以字面量类型的形式,好比T这个对象的键名包括a b c,那么U的取值只能是"a" "b" "c"之一,即 "a" | "b" | "c")。具体细节咱们会在 条件类型 一章讲到。

假设如今不仅要取出一个值了,咱们要取出一系列值,即参数 2 将是一个数组,成员均为参数 1 的键名组成:

function pick<T extends object, U extends keyof T>(obj: T, keys: U[]): T[U][] {
  return keys.map((key) => obj[key]);
}

// pick(obj, ['a', 'b'])
复制代码

有两个重要变化:

  • keys: U[] 咱们知道 U 是 T 的键名组成的联合类型,那么要表示一个内部元素均是 T 键名的数组,就可使用这种方式,具体的原理请参见下文的 分布式条件类型 章节。

  • T[U][] 它的原理实际上和上面一条相同,首先是T[U],表明参数1的键值(就像Object[Key]),我认为它是一个很好地例子,表现了 TS 类型编程的组合性,你不感受这种写法就像搭积木同样吗?

索引签名 Index Signature

在JavaScript中,咱们一般使用 arr[1] 的方式索引数组,使用 obj[key] 的方式索引对象。说白了,索引就是你获取一个对象成员的方式,而在类型编程中,索引签名用于快速创建一个内部字段类型相同的接口,如

interface Foo {
  [keys: string]: string;
}
复制代码

那么接口 Foo 实际上等价于一个键值所有为 string 类型,不限制成员的接口。

等同于Record<string, string>,见 工具类型。

值得注意的是,因为 JS 能够同时经过数字与字符串访问对象属性,所以keyof Foo的结果会是string | number

const o: Foo = {
 1: "芜湖!",
};

o[1] === o["1"]; // true
复制代码

可是一旦某个接口的索引签名类型为number,那么使用它的对象就不能再经过字符串索引访问,如o['1'],将会抛出错误, 元素隐式具备 "any" 类型,由于索引表达式的类型不为 "number"。

映射类型 Mapped Types

在开始映射类型前,首先想一想 JavaScript 中数组的 map 方法,经过使用map,咱们从一个数组按照既定的映射关系得到一个新的数组。在类型编程中,咱们则会从一个类型定义(包括但不限于接口、类型别名)映射获得一个新的类型定义。一般会在旧有类型的基础上进行改造,如:

  • 修改原接口的键值类型

  • 为原接口键值类型新增修饰符,如 readonly 与 可选?

从一个简单场景入手:

interface A {
  a: boolean;
  b: string;
  c: number;
  d: () => void;
}
复制代码

如今咱们有个需求,实现一个接口,它的字段与接口 A 彻底相同,可是其中的类型所有为 string,你会怎么作?直接从新声明一个而后手写吗?这样就很离谱了,咱们但是机智的程序员。

若是把接口换成对象再想一想,假设要拷贝一个对象(假设没有嵌套,不考虑引用类型变量存放地址),经常使用的方式是首先 new 一个新的空对象,而后遍历原先对象的键值对来填充新对象。而接口其实也同样:

type StringifyA<T> = {
  [K in keyof T]: string;
};
复制代码

是否是很熟悉?重要的就是这个in操做符,你彻底能够把它理解为 for...in/for...of 这种遍历的思路,获取到键名以后,键值就简单了,因此咱们能够很容易的拷贝一个新的类型别名出来。

type ClonedA<T> = {
  [K in keyof T]: T[K];
};
复制代码

掌握这种思路,其实你已经接触到一些工具类型的底层实现了:

你能够把工具类型理解为你平时放在 utils 文件夹下的公共函数,提供了对公用逻辑(在这里则是类型编程逻辑)的封装,好比上面的两个类型接口就是。关于更多工具类型,参考 工具类型 一章。

先写个最经常使用的 Partial尝尝鲜,工具类型的详细介绍咱们会在专门的章节展开:

// 将接口下的字段所有变为可选的
type Partial<T> = {
  [K in keyof T]?: T[k];
};
复制代码

key?: value 意为这一字段是可选的,在大部分状况下等同于 key: value | undefined

条件类型 Conditional Types

在编程中遇到条件判断,咱们经常使用 If 语句与三元表达式实现,我我的偏心后者,即便是:

if (condition) {
  execute()
}
复制代码

这种没有 else 的 If 语句,我也习惯写成:

condition ? execute() : void 0;
复制代码

而 条件类型 的语法,实际上就是三元表达式,看一个最简单的例子:

T extends U ? X : Y
复制代码

若是你以为这里的 extends 不太好理解,能够暂时简单理解为 U 中的属性在 T 中都有。

为何会有条件类型?能够看到 条件类型 一般是和 泛型 一同使用的,联想到泛型的使用场景以及值得延迟推断,我想你应该明白了些什么。对于类型没法即时肯定的场景,使用 条件类型 来在运行时动态的肯定最终的类型(运行时可能不太准确,或者能够理解为,你提供的函数被他人使用时,根据他人使用时传入的参数来动态肯定须要被知足的类型约束)。

类比到编程语句中,其实就是根据条件判断来动态的赋予变量值:

let unknownVar: string;

unknownVar = condition ? "淘系前端" : "淘宝FED";

type LiteralType<T> = T extends string ? "foo" : "bar";
复制代码

条件类型理解起来其实也很直观,惟一须要有必定理解成本的就是 什么时候条件类型系统会收集到足够的信息来肯定类型,也就是说,条件类型有时不会马上完成判断,好比工具库提供的函数,须要用户在使用时传入参数才会完成 条件类型 的判断。

在了解这一点前,咱们先来看看条件类型经常使用的一个场景:泛型约束,实际上就是咱们上面 索引类型 的例子:

function pickSingleValue<T extends object, U extends keyof T>(
  obj: T,
  key: U
): T[U] {
  return obj[key];
}
复制代码

这里的 T extends objectU extends keyof T 都是泛型约束,分别将 T 约束为对象类型将 U 约束为 T 键名的字面量联合类型(不记得了?提示:1 | 2 | 3)。咱们一般使用泛型约束来 收窄类型约束,简单的说,泛型自己是来者不拒的,全部类型都能被 显式传入(如 Array<number>) 或者 隐式推导 (如 foo(1)),这样其实不是咱们想要的,就像咱们有时会检测函数的参数:

function checkArgFirst(arg){
  if(typeof arg !== "number"){
    throw new Error("arg must be number type!")
  }
}
复制代码

在 TS 中,咱们经过泛型约束,要求传入的泛型只能是固定的类型,如 T extends {} 约束泛型至对象类型,T extends number | string将泛型约束至数字与字符串类型。

以一个使用条件类型做为函数返回值类型的例子:

declare function strOrNum<T extends boolean>(
  x: T
): T extends true ? string : number;
复制代码

在这种状况下,条件类型的推导就会被延迟,由于此时类型系统没有足够的信息来完成判断。

只有给出了所需信息(在这里是入参 x 的类型),才能够完成推导。

const strReturnType = strOrNum(true);
const numReturnType = strOrNum(false);
复制代码

一样的,就像三元表达式能够嵌套,条件类型也能够嵌套,若是你看过一些框架源码,也会发现其中存在着许多嵌套的条件类型,无他,条件类型能够将类型约束收拢到很是窄的范围内,提供精确的条件类型,如:

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

分布式条件类型实际上不是一种特殊的条件类型,而是其特性之一(因此说条件类型的分布式特性更为准确)。咱们直接先上概念: 对于属于裸类型参数的检查类型,条件类型会在实例化时期自动分发到联合类型上

原文:

Conditional types in which the checked type is a naked type parameter are called distributive conditional types. Distributive conditional types are automatically distributed over union types during instantiation

先提取几个关键词,而后咱们再经过例子理清这个概念:

  • 裸类型参数(类型参数即泛型,见文章开头的泛型章节介绍)

  • 实例化

  • 分发到联合类型

    // 使用上面的TypeName类型别名

    // "string" | "function" type T1 = TypeName<string | (() => void)>;

    // "string" | "object" type T2 = TypeName<string | string[]>;

    // "object" type T3 = TypeName<string[] | number[]>;

咱们发如今上面的例子里,条件类型的推导结果都是联合类型(T3 实际上也是,只不过由于结果相同因此被合并了),而且其实就是类型参数被依次进行条件判断后,再使用|组合得来的结果。

是否是 get 到了一点什么?上面的例子中泛型都是裸露着的,若是被包裹着,其条件类型判断结果会有什么变化吗?咱们再看另外一个例子:

type Naked<T> = T extends boolean ? "Y" : "N";
type Wrapped<T> = [T] extends [boolean] ? "Y" : "N";

// "N" | "Y"
type Distributed = Naked<number | boolean>;

// "N"
type NotDistributed = Wrapped<number | boolean>;
复制代码
  • 其中,Distributed类型别名,其类型参数(number | boolean)会正确的分发,即
    先分发到 Naked<number> | Naked<boolean>,再进行判断,因此结果是"N" | "Y"

  • 而 NotDistributed 类型别名,第一眼看上去感受TS应该会自动按数组进行分发,结果应该也是 "N" | "Y" ?但实际上,它的类型参数(number | boolean)不会有分发流程,直接进行[number | boolean] extends [boolean]的判断,因此结果是"N"

如今咱们能够来说讲这几个概念了:

  • 裸类型参数,没有额外被[]包裹过的,就像被数组包裹后就不能再被称为裸类型参数。

  • 实例化,其实就是条件类型的判断过程,就像咱们前面说的,条件类型须要在收集到足够的推断信息以后才能进行这个过程。在这里两个例子的实例化过程其实是不一样的,具体会在下一点中介绍。

  • 分发到联合类型:

  • 对于 TypeName,它内部的类型参数 T 是没有被包裹过的,因此 TypeName<string | (() => void)> 会被分发为 TypeName<string> | TypeName<(() => void)>,而后再次进行判断,最后分发为"string" | "function"

  • 抽象下具体过程:

    ( A | B | C ) extends T ? X : Y
    // 至关于
    (A extends T ? X : Y) | (B extends T ? X : Y) | (B extends T ? X : Y)
    
    // 使用[]包裹后,不会进行额外的分发逻辑。
    [A | B | C] extends [T] ? X : Y
    复制代码

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

这两种行为没有好坏之分,区别只在因而否进行联合类型的分发,若是你须要走分布式条件类型,那么注意保持你的类型参数为裸类型参数。若是你想避免这种行为,那么使用 [] 包裹你的类型参数便可(注意在 extends 关键字的两侧都须要)。

infer 关键字

在条件类型中,咱们展现了如何经过条件判断来延迟肯定类型,但仅仅使用条件类型也有必定不足:它没法从条件上获得类型信息。举例来讲,T extends Array<PrimitiveType> ? "foo" : "bar"这一例子,咱们不能从做为条件的 Array<PrimitiveType> 中获取到 PrimitiveType 的实际类型。

而这样的场景又是十分常见的,如获取函数返回值的类型、拆箱Promise / 数组等,所以这一节咱们来介绍下 infer 关键字。

inferinference 的缩写,一般的使用方式是用于修饰做为类型参数的泛型,如: infer RR表示 待推断的类型。一般 infer不会被直接使用,而是与条件类型一块儿,被放置在底层工具类型中。若是说条件类型提供了延迟推断的能力,那么加上 infer 就是提供了基于条件进行延迟推断的能力。

看一个简单的例子,用于获取函数返回值类型的工具类型ReturnType:

const foo = (): string => {
  return "linbudu";
};

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

// string
type FooReturnType = ReturnType<typeof foo>;
复制代码
  • (...args: any[]) => infer R 是一个总体,这里函数的返回值类型的位置被 infer R 占据了。

  • ReturnType 被调用,类型参数 T 、R 被显式赋值(T为 typeof fooinfer R被总体赋值为string,即函数的返回值类型),若是 T 知足条件类型的约束,就返回 infer 完毕的R 的值,在这里 R 即为函数的返回值实际类型。

  • 实际上为了严谨,应当约束泛型T为函数类型,即:

    // 第一个 extends 约束可传入的泛型只能为函数类型
    // 第二个 extends 做为条件判断
    type ReturnType<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : never;
    复制代码

infer的使用思路可能不是那么好习惯,咱们能够用前端开发中常见的一个例子类比,页面初始化时先显示占位交互,像 Loading / 骨架屏,在请求返回后再去渲染真实数据。infer也是这个思路,类型系统在得到足够的信息(一般来自于条件的延迟推断)后,就能将 infer 后跟随的类型参数推导出来,最后一般会返回这个推导结果。

相似的,借着这个思路咱们还能够得到函数入参类型、类的构造函数入参类型、甚至 Promise 内部的类型等,这些工具类型咱们会在后面讲到。

另外,对于 TS 中函数重载的状况,使用 infer (如上面的 ReturnType)不会为全部重载执行推导过程,只有最后一个重载(由于通常来讲最后一个重载一般是最普遍的状况)会被使用。

工具类型 Tool Type

这一章应该是本文“性价比”最高的一部分了,由于即便你在阅读完这部分后,仍是不太懂这些工具类型是如何实现的,也不影响你把它用的恰到好处,就像 Lodash 不会要求你对每一个使用的函数都熟知原理同样。

这一部分包括 TS 内置工具类型 与社区的 扩展工具类型,我我的推荐在完成学习后挑选一部分工具类型记录下来,好比你以为比较有价值、现有或者将来业务可能会使用,或者仅仅是以为很好玩的工具类型,并在本身的项目里新建一个.d.ts文件(或是 /utils/tool-types.ts 这样)存储它。

在继续阅读前,最好确保你掌握了上面的知识,它们是工具类型的基础。

内置工具类型

在上面咱们已经实现了内置工具类型中被使用最多的一个:

type Partial<T> = {
  [K in keyof T]?: T[k];
};
复制代码

它用于将一个接口中的字段所有变为可选,除了索引类型以及映射类型之外,它只使用了?可选修饰符,那么我如今直接掏出小抄:

  • 去除可选修饰符:-?,位置与 ? 一致

  • 只读修饰符:readonly,位置在键名,如 readonly key: string

  • 去除只读修饰符:-readonly,位置同readonly

恭喜,你获得了 RequiredReadonly(去除 readonly 修饰符的工具类型不属于内置的,咱们会在后面看到):

type Required<T> = {
  [K in keyof T]-?: T[K];
};

type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};
复制代码

在上面咱们实现了一个 pick 函数:

function pick<T extends object, U extends keyof T>(obj: T, keys: U[]): T[U][] {
  return keys.map((key) => obj[key]);
}
复制代码

相似的,假设咱们如今须要从一个接口中挑选一些字段:

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

// 指望用法
// 指望结果 A["a"]类型 | A["b"]类型
type Part = Pick<A, "a" | "b">;
复制代码

仍是映射类型,只不过如今映射类型的映射源是传入给 Pick 的类型参数K。

既然有了Pick,那么天然要有Omit(一个是从对象中挑选部分,一个是排除部分),它和Pick的写法很是像,但有一个问题要解决:咱们要怎么表示T中剔除了K后的剩余字段?

Pick 选取传入的键值,Omit 移除传入的键值

这里咱们又要引入一个知识点:never类型,它表示永远不会出现的类型,一般被用来将收窄联合类型或是接口,或者做为条件类型判断的兜底。详细能够看 尤大的知乎回答, 在这里咱们不作展开介绍。

上面的场景其实能够简化为:

// "3" | "4" | "5"
type LeftFields = Exclude<"1" | "2" | "3" | "4" | "5", "1" | "2">;
复制代码

Exclude,字面意思看起来是排除,那么第一个参数应该是要进行筛选的,第二个应该是筛选条件!先按着这个思路试试:

这里实际上使用到了分布式条件类型的特性,假设 Exclude 接收 T U 两个类型参数,T 联合类型中的类型会依次与 U 类型进行判断,若是这个类型参数在 U 中,就剔除掉它(赋值为 never)

接地气的版本:"1""1" | "2" 里面吗( "1" extends "1"|"2" -> true )? 在的话,就剔除掉它(赋值为never),不在的话就保留。

type Exclude<T, U> = T extends U ? never : T;
复制代码

那么 Omit就很简单了,对原接口的成员,剔除掉传入的联合类型成员,应用 Pick 便可。

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
复制代码

剧透下,几乎全部使用条件类型的场景,把判断后的赋值语句反一下,就会有新的场景,好比 Exclude 移除掉键名,那反一下就是保留键名:

type Extract<T, U> = T extends U ? T : never;
复制代码

再来看个经常使用的工具类型 Record<Keys, Type>,一般用于生成以联合类型为键名(Keys),键值类型为Type的新接口,好比:

type MyNav = "a" | "b" | "b";
interface INavWidgets {
  widgets: string[];
  title?: string;
  keepAlive?: boolean;
}
const router: Record<MyNav, INavWidgets> = {
  a: { widget: [""] },
  b: { widget: [""] },
  c: { widget: [""] },
};
复制代码

其实很简单,把 Keys 的每一个键值拿出来,类型规定为 Type 便可

// K extends keyof any 约束K必须为联合类型
type Record<K extends keyof any, T> = {
  [P in K]: T;
};
复制代码

注意,Record也支持 Record<string, unknown> 这样的使用方式, string extends keyof any 也是成立的,由于 keyof 的最终结果必然是 string 组成的联合类型(除了使用数字做为键名的状况...)。

在前面的 infer 一节中咱们实现了用于获取函数返回值的ReturnType

type ReturnType<T extends (...args: any) => any> = T extends (
  ...args: any
) => infer R
  ? R
  : any;
复制代码

其实把 infer 换个位置,好比放到入参处,它就变成了获取参数类型的Parameters:

type Parameters<T extends (...args: any) => any> = T extends (
  ...args: infer P
) => any
  ? P
  : never;
复制代码

若是再大胆一点,把普通函数换成类的构造函数,那么就获得了获取类构造函数入参类型的ConstructorParameters

type ConstructorParameters<
  T extends new (...args: any) => any
> = T extends new (...args: infer P) => any ? P : never;
复制代码

加上new关键字来使其成为可实例化类型声明,即此处约束泛型为

这个是得到类的构造函数入参类型,若是把待 infer 的类型放到其返回处,想一想 new 一个类的返回值是什么?实例!因此咱们获得了实例类型InstanceType

type InstanceType<T extends new (...args: any) => any> = T extends new (
  ...args: any
) => infer R
  ? R
  : any;
复制代码

这几个例子看下来,你应该已经 get 到了那么一丝天机,类型编程的确没有特别高深晦涩的语法,它考验的是你对其中基础部分如索引映射条件类型的掌握程度,以及触类旁通的能力。下面咱们要学习的社区工具类型,本质上仍是各类基础类型的组合,只是从常见场景下出发,补充了官方没有覆盖到的部分。

社区工具类型

这一部分的工具类型大多来自于utility-types,其做者同时还有 react-redux-typescript-guidetypesafe-actions 这两个优秀做品。

同时,也推荐 type-fest 这个库,和上面相比更加接地气一些。其做者的做品...,我保证你直接或间接的使用过(若是不信,必定要去看看,我刚看到的时候是真的震惊的不行)。

咱们由浅入深,先封装基础的类型别名和对应的类型守卫:

export type Primitive =
  | string
  | number
  | bigint
  | boolean
  | symbol
  | null
  | undefined;

export const isPrimitive = (val: unknown): val is Primitive => {
  if (val === null || val === undefined) {
    return true;
  }

  const typeDef = typeof val;

  const primitiveNonNullishTypes = [
    "string",
    "number",
    "bigint",
    "boolean",
    "symbol",
  ];

  return primitiveNonNullishTypes.indexOf(typeDef) !== -1;
};

export type Nullish = null | undefined;

export type NonUndefined<A> = A extends undefined ? never : A;
// 实际上TS也内置了这个工具类型
type NonNullable<T> = T extends null | undefined ? never : T;
复制代码

FalsyisFalsy 咱们已经在上面体现过了。

趁着对 infer 的记忆来热乎,咱们再来看一个经常使用的场景,提取 Promise 的实际类型:

const foo = (): Promise<string> => {
  return new Promise((resolve, reject) => {
    resolve("linbudu");
  });
};

// Promise<string>
type FooReturnType = ReturnType<typeof foo>;

// string
type NakedFooReturnType = PromiseType<FooReturnType>;
复制代码

若是你已经熟练掌握了infer的使用,那么其实是很好写的,只须要用一个infer参数做为 Promise 的泛型便可:

export type PromiseType<T extends Promise<any>> = T extends Promise<infer U>
  ? U
  : never;
复制代码

使用 infer R 来等待类型系统推导出R的具体类型。

递归的工具类型

前面咱们写了个Partial Readonly Required等几个对接口字段进行修饰的工具类型,但实际上都有局限性,若是接口中存在着嵌套呢?

type Partial<T> = {
  [P in keyof T]?: T[P];
};
复制代码

理一下逻辑:

  • 若是不是对象类型,就只是加上?修饰符

  • 若是是对象类型,那就遍历这个对象内部

  • 重复上述流程。

是不是对象类型的判断咱们见过不少次了, T extends object便可,那么如何遍历对象内部?实际上就是递归。

export type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
复制代码

utility-types内部的实现实际比这个复杂,还考虑了数组的状况,这里为了便于理解作了简化,后面的工具类型也一样存在此类简化。

那么DeepReadoblyDeepRequired也就很简单了:

export type DeepMutable<T> = {
  -readonly [P in keyof T]: T[P] extends object ? DeepMutable<T[P]> : T[P];
};

// 即DeepReadonly
export type DeepImmutable<T> = {
  +readonly [P in keyof T]: T[P] extends object ? DeepImmutable<T[P]> : T[P];
};

export type DeepRequired<T> = {
  [P in keyof T]-?: T[P] extends object | undefined ? DeepRequired<T[P]> : T[P];
};
复制代码

尤为注意下DeepRequired,它的条件类型判断的是 T[P] extends object | undefined,由于嵌套的对象类型多是可选的(undefined),若是仅使用object,可能会致使错误的结果。

另一种省心的方式是不进行条件类型的判断,直接全量递归全部属性~

返回键名的工具类型

在有些场景下咱们须要一个工具类型,它返回接口字段键名组成的联合类型,而后用这个联合类型进行进一步操做(好比给 Pick 或者 Omit 这种使用),通常键名会符合特定条件,好比:

  • 可选/必选/只读/非只读的字段

  • (非)对象/(非)函数/类型的字段

来看个最简单的函数类型字段FunctionTypeKeys

export type FunctTypeKeys<T extends object> = {
  [K in keyof T]-?: T[K] extends Function ? K : never;
}[keyof T];
复制代码

{[K in keyof T]: ... }[keyof T]这个写法可能有点诡异,拆开来看:

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 UseIt1 = WTFIsThis<IWithFuncKeys>;
复制代码

很容易推导出 UseIt1 实际上就是:

type UseIt1 = {
  a: never;
  b: never;
  c: never;
  d: "d";
};
复制代码

UseIt会保留全部字段,知足条件的字段其键值为字面量类型(即键名),不知足的则为never。

加上后面一部分:

// "d"
type UseIt2 = UseIt1[keyof UseIt1];
复制代码

这个过程相似排列组合:never类型的值不会出如今联合类型中

// never类型会被自动去除掉 string | number
type WithNever = string | never | number;
复制代码

因此{ [K in keyof T]: ... }[keyof T]这个写法实际上就是为了返回键名(准备的说,是键名组成的联合类型)。

那么非函数类型字段也很简单了,这里就不作展现了,下面来看可选字段OptionalKeys与必选字段RequiredKeys,先来看个小例子:

type WTFAMI1 = {} extends { prop: number } ? "Y" : "N";
type WTFAMI2 = {} extends { prop?: number } ? "Y" : "N";
复制代码

若是能绕过来,很容易就能得出来答案。若是一时没绕过去,也很简单,对于前面一个状况,prop是必须的,所以空对象 {} 并不能知足extends { prop: number },而对于prop为可选的状况下则能够。

所以,咱们使用这种思路来获得可选/必选的键名。

  • {} extends Pick<T, K>,若是K是可选字段,那么就留下(OptionalKeys,若是是 RequiredKeys 就剔除)。

  • 怎么剔除?固然是用never了。

    export type RequiredKeys = { [K in keyof T]-?: {} extends Pick<T, K> ? never : K; }[keyof T];

这里是剔除可选字段,那么 OptionalKeys 就是保留了:

export type OptionalKeys<T> = {
  [K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];
复制代码

只读字段IMmutableKeys与非只读字段MutableKeys的思路相似,即先得到:

interface MutableKeys {
  readonlyKeys: never;
  notReadonlyKeys: "notReadonlyKeys";
}
复制代码

而后再得到不为never的字段名便可。

这里仍是要表达一下对做者的敬佩,属实巧妙啊,首先定义一个工具类型IfEqual,比较两个类型是否相同,甚至能够比较修饰先后的状况下,也就是这里只读与非只读的状况。

type Equal<X, Y, A = X, B = never> = (<T>() => T extends X ? 1 : 2) extends <
  T
>() => T extends Y ? 1 : 2
  ? A
  : B;
复制代码
  • 不要被<T>() => T extends X ? 1 : 2干扰,能够理解为就是用于比较的包装,这一层包装可以区分出来只读与非只读属性。即 (<T>() => T extends X ? 1 : 2) 这一部分,只有在类型参数 X 彻底一致时,两个 (<T>() => T extends X ? 1 : 2) ` 才会是全等的,这个一致要求只读性、可选性等修饰也要一致。

  • 实际使用时(以非只读的状况为例),咱们为 X 传入接口,为 Y 传入去除了只读属性-readonly的接口,使得全部键都被进行一次与去除只读属性的键的比较。为 A 传入字段名,B 这里咱们须要的就是 never,所以能够不填。

实例:

export type MutableKeys<T extends object> = {
  [P in keyof T]-?: Equal<
    { [Q in P]: T[P] },
    { -readonly [Q in P]: T[P] },
    P,
    never
  >;
}[keyof T];
复制代码

几个容易绕弯子的点:

  • 泛型 Q 在这里不会实际使用,只是映射类型的字段占位。

  • X 、 Y 一样存在着 分布式条件类型, 来依次比对字段去除 readonly 先后。

一样的有:

export type IMmutableKeys<T extends object> = {
  [P in keyof T]-?: Equal<
    { [Q in P]: T[P] },
    { -readonly [Q in P]: T[P] },
    never,
    P
  >;
}[keyof T];
复制代码
  • 这里不是对readonly修饰符操做,而是调换条件类型的判断语句。

基于值类型的 Pick 与 Omit

前面咱们实现的 Pick 与 Omit 是基于键名的,假设如今咱们须要按照值类型来作选取剔除呢?

其实很简单,就是T[K] extends ValueType便可:

export type PickByValueType<T, ValueType> = Pick<
  T,
  { [Key in keyof T]-?: T[Key] extends ValueType ? Key : never }[keyof T]
>;

export type OmitByValueType<T, ValueType> = Pick<
  T,
  { [Key in keyof T]-?: T[Key] extends ValueType ? never : Key }[keyof T]
>;
复制代码

条件类型承担了太多...

工具类型一览

总结下咱们上面书写的工具类型:

  • 全量修饰接口:Partial Readonly(Immutable) Mutable Required,以及对应的递归版本。

  • 裁剪接口:Pick Omit PickByValueType OmitByValueType

  • 基于 infer:ReturnType ParamType PromiseType

  • 获取指定条件字段:FunctionKeys OptionalKeys RequiredKeys ...

须要注意的是,有时候单个工具类型并不能知足你的要求,你可能须要多个工具类型协做,好比用 FunctionKeys + Pick 获得一个接口中类型为函数的字段。

另外,实际上上面的部分工具类型是能够用重映射能力实现的更加简洁优雅的,这不尝试下?

受限于篇幅(本文到这里已经1.3w字了),原本还想放上来的 type-fest 的工具类型就只能遗憾退场了,但我仍是建议你们去读一读它的源码。相比于上面的 utility-types 更加接地气,实现思路也更加有趣。

TypeScript 4.x 中的部分新特性

这一部分是相对于以前的版本新增的部分,主要包括了4.1 - 4.4(Beta)版本中引入的一部分与本文介绍内容有关的新特性,包括 模板字面量类型 与 重映射。

模板字面量类型

TypeScript 4.1 中引入了模板字面量类型,使得咱们可使用${} 这一语法来构造字面量类型,如:

type World = 'world';

// "hello world"
type Greeting = `hello ${World}`;
复制代码

模板字面量类型一样支持分布式条件类型,如:

export type SizeRecord<Size extends string> = `${Size}-Record`

// "Small-Record"
type SmallSizeRecord = SizeRecord<"Small">
// "Middle-Record"
type MiddleSizeRecord = SizeRecord<"Middle">
// "Huge-Record"
type HugeSizeRecord = SizeRecord<"Huge">


// "Small-Record" | "Middle-Record" | "Huge-Record"
type UnionSizeRecord = SizeRecord<"Small" | "Middle" | "Huge">
复制代码

还有个有趣的地方,模板插槽(${})中能够传入联合类型,而且同一模板中若是存在多个插槽,各个联合类型将会被分别排列组合。

// "Small-Record" | "Small-Report" | "Middle-Record" | "Middle-Report" | "Huge-Record" | "Huge-Report"
type SizeRecordOrReport = `${"Small" | "Middle" | "Huge"}-${"Record" | "Report"}`;
复制代码

随之而来的还有四个新的工具类型:

type Uppercase<S extends string> = intrinsic;

type Lowercase<S extends string> = intrinsic;

type Capitalize<S extends string> = intrinsic;

type Uncapitalize<S extends string> = intrinsic;
复制代码

它们的做用就是字面意思,不作解释了。相关的PR见 40336,做者Anders Hejlsberg 是 C# 与 Delphi 的首席架构师,同时也是TS的做者之一。

intrinsic表明了这些工具类型是由 TS 编译器内部实现的,其实也很好理解,咱们没法经过类型编程来改变字面量的值,但我想按照这个趋势,TS类型编程之后会支持调用 Lodash 方法也说不定。

TS 的实现代码:

function applyStringMapping(symbol: Symbol, str: string) {
 switch (intrinsicTypeKinds.get(symbol.escapedName as string)) {
     case IntrinsicTypeKind.Uppercase: return str.toUpperCase();
     case IntrinsicTypeKind.Lowercase: return str.toLowerCase();
     case IntrinsicTypeKind.Capitalize: return str.charAt(0).toUpperCase() + str.slice(1);
     case IntrinsicTypeKind.Uncapitalize: return str.charAt(0).toLowerCase() + str.slice(1);
 }
 return str;
}
复制代码

你可能会想到,模板字面量若是想截取其中的一部分要怎么办?这里可无法调用 slice 方法。其实思路就在咱们上面提到过的 infer,使用 infer 占位后,便可以提取出字面量的一部分,如:

type CutStr<Str extends string> = Str extends `${infer Part}budu` ? Part : never

// "lin"
type Tmp = CutStr<"linbudu">
复制代码

再进一步,[1,2,3]这样的字符串,若是咱们提供 [${infer Member1}, ${infer Member2}, ${infer Member}] 这样的插槽匹配,就能够实现神奇的提取字符串数组成员效果:

type ExtractMember<Str extends string> = Str extends `[${infer Member1}, ${infer Member2}, ${infer Member3}]` ? [Member1, Member2, Member3] : unknown;

// ["1", "2", "3"]
type Tmp = ExtractMember<"[1, 2, 3]">
复制代码

注意,这里的模板插槽被使用 , 分隔开了,若是多个带有 infer 的插槽紧挨在一块儿,那么前面的 infer 只会得到单个字符,最后一个 infer 会得到全部的剩余字符(若是有的话),好比咱们把上面的例子改为这样:

type ExtractMember<Str extends string> = Str extends `[${infer Member1}${infer Member2}${infer Member3}]` ? [Member1, Member2, Member3] : unknown;

// ["1", ",", " 2, 3"]
type Tmp = ExtractMember<"[1, 2, 3]">
复制代码

这一特性使得咱们可使用多个相邻的 infer + 插槽,对最后一个 infer得到的值进行递归操做,如:

type JoinArrayMember<T extends unknown[], D extends string> =
  T extends [] ? '' :
  T extends [any] ? `${T[0]}` :
  T extends [any, ...infer U] ? `${T[0]}${D}${JoinArrayMember<U, D>}` :
  string;

// ""
type Tmp1 = JoinArrayMember<[], '.'>;
// "1"
type Tmp3 = JoinArrayMember<[1], '.'>;
// "1.2.3.4"
type Tmp2 = JoinArrayMember<[1, 2, 3, 4], '.'>;
复制代码

原理也很简单,每次将数组的第一个成员添加上.,在最后一个成员时不做操做,在最后一次匹配([])返回空字符串,便可。

又或者反过来?把 1.2.3.4 回归到数组形式?

type SplitArrayMember<S extends string, D extends string> =
  string extends S ? string[] :
  S extends '' ? [] :
  S extends `${infer T}${D}${infer U}` ? [T, ...SplitArrayMember<U, D>] :
  [S];

type Tmp11 = SplitArrayMember<'foo', '.'>;  // ['foo']
type Tmp12 = SplitArrayMember<'foo.bar.baz', '.'>;  // ['foo', 'bar', 'baz']
type Tmp13 = SplitArrayMember<'foo.bar', ''>;  // ['f', 'o', 'o', '.', 'b', 'a', 'r']
type Tmp14 = SplitArrayMember<any, '.'>;  // stri
复制代码

最后,看到 a.b.c 这样的形式,你应该想到了 Lodash 的 get 方法,即经过 get({},"a.b.c") 的形式快速得到嵌套属性。可是这样要怎么提供类型声明?有了模板字面量类型后,只须要结合 infer + 条件类型便可。

type PropType<T, Path extends string> =
    string extends Path ? unknown :
    Path extends keyof T ? T[Path] :
    Path extends `${infer K}.${infer R}` ? K extends keyof T ? PropType<T[K], R> : unknown :
    unknown;

declare function getPropValue<T, P extends string>(obj: T, path: P): PropType<T, P>;
declare const s: string;

const obj = { a: { b: {c: 42, d: 'hello' }}};
getPropValue(obj, 'a');  // { b: {c: number, d: string } }
getPropValue(obj, 'a.b');  // {c: number, d: string }
getPropValue(obj, 'a.b.d');  // string
getPropValue(obj, 'a.b.x');  // unknown
getPropValue(obj, s);  // unknown
复制代码

重映射

这一能力在 TS 4.1 中引入,提供了在映射类型中重定向映射源至新类型的能力,这里的新类型能够是工具类型的返回结果、字面量模板类型等,用于解决在使用映射类型时,咱们想要过滤/新增拷贝的接口成员,一般会将原接口成员的键做为新的转换方法参数,如:

type Getters<T> = {
    [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};

interface Person {
    name: string;
    age: number;
    location: string;
}

type LazyPerson = Getters<Person>;
复制代码

转换后的结果:

type LazyPerson = {
    getName: () => string;
    getAge: () => number;
    getLocation: () => string;
}
复制代码

这里的 string & k 是由于重映射的转换方法(即 as 后面的部分)必须是可分配给 string | number | symbol 的,而 K 来自于 keyof,可能包含 symbol 类型,这样的话是不能交给模板字面量类型使用的。

若是转换方法返回了never,那么这个成员就被除去了,因此咱们可使用这个方法来过滤掉成员。

type RemoveKindField<T> = {
    [K in keyof T as Exclude<K, "kind">]: T[K]
};

interface Circle {
    kind: "circle";
    radius: number;
}

// type KindlessCircle = {
//     radius: number;
// }
type KindlessCircle = RemoveKindField<Circle>;
复制代码

最后,当与模板字面量一同使用时,因为其排列组合的特性,若是重映射的转换方法是一个由 模板字面量类型 组成的 联合类型,那么就会从排列组合获得多个成员。

type DoubleProp<T> = { [P in keyof T & string as `${P}1` | `${P}2`]: T[P] }
type Tmp = DoubleProp<{ a: string, b: number }>;  // { a1: string, a2: string, b1: number, b2: number }
复制代码

尾声

这篇文章确实很长很长,因不建议一次性囫囵吞枣的读完,建议选取几段有必定长度的连续时间,给它掰开了揉碎了好好读懂。写文不易,尤为是写这么长的文章,可是若是能帮助你的 TypeScript 更上一层楼,就彻底值得了。

若是在以前,你从未关注过类型编程方面,那么阅读完毕后可能须要必定时间来适应思路的转变。仍是那句话,认识到 类型编程的本质也是编程。固然,你也能够渐进式的开始实践这一点,好比从今天开始,从如今手头里的项目开始,从泛型到类型守卫,从索引/映射类型到条件类型,从使用工具类型到封装工具类型,一步步变成 TypeScript 高高手。

相关文章
相关标签/搜索