你所不了解的TypeScript 类型编程

发表于 3年以前  | 总阅读数:315 次

前言

作为前端开发的趋势之一,TypeScript正在越来越普及,很多人像我一样写了TS后再也回不去了,比如写算法题写demo都用TS,JS只有在Webpack配置(实际上这也可以用TS写)等少的可怜的情况下才会用到(有了ts-node后,我连爬虫都用ts写了)。TS的学习成本实际上并不高(的确是,具体原因我在下面会讲,别急着锤我),我个人认为它可以被分成两个部分:

  • 预实现的ES提案,如 装饰器(我之前的一篇文章 走近MidwayJS:初识TS装饰器与IoC机制 中讲了一些关于TS装饰器的历史, 有兴趣的可以看看), 可选链?. ,空值合并运算符??,类的私有成员private等。除了部分语法如装饰器以外,大部分的预实现实际上就是未来的ES语法。对于这一部分来说,无论你先前是只学习过JS(就像我一样),还是有过Java、C#的使用经历,都能非常快速地上手,这也是实际开发中使用最多的部分,毕竟和另一块-类型编程比起来,还是这一部分更接地气。
  • 类型编程,无论是一个普通接口(interface)或是类型别名type,还是密密麻麻的extends``infer 工具类型blabla...(下文会展开介绍),我个人认为都属于类型编程的范畴。这一块实际上对代码的功能层面没有任何影响,即使你把它写成anyscript,代码该咋样还是咋样。而这也就是类型编程一直不受到太多重视的原因:相比于语法,它会带来代码量大大增多(可能接近甚至超过业务代码),编码耗时增长(头发--)等问题,而带来的唯一好处就是 类型安全 , 包括如臂使指的类型提示(VS Code YES!),进一步减少可能存在的调用错误,以及降低维护成本。看起来似乎有得有失,但实际上,假设你花费1单位脑力使用基础的TS以及简单的类型编程,你就能够获得5个单位的回馈。但接下来,有可能你花费10个单位脑力,也只能再获得2个单位的回馈。另外一个类型编程不受重视的原因则是实际业务中并不会需要多么苛刻的类型定义,通常是底层框架类库才会有此类需求,这一点就见仁见智了,但我想没人会想永远当业务仔吧(没有阴阳怪气的意思)。

正文部分包括:

  • 基础泛型
  • 索引类型 & 映射类型
  • 条件类型 & 分布式条件类型
  • infer关键字
  • 类型守卫 is in 关键字
  • 内置工具类型机能与原理
  • 内置工具类型增强
  • 更多通用工具类型

这些名词可能看着有点劝退,但我会尽可能描述的通俗易懂,让你在阅读时不断发出“就这?”的感慨:)

为了适配所有基础的读者,本文会讲解的尽可能细致,如果你已经熟悉某部分知识,请跳过~

泛型 Generic Type

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

function foo(args: unknown): unknown { ... }

如果它接收一个字符串,返回这个字符串的部分截取,如果接收一个数字,返回这个数字的n倍,如果接收一个对象,返回键值被更改过的对象(键名不变),如果这时候需要类型定义,是否要把unknown替换为string | number | object?这样固然可以,但别忘记我们需要的是 入参与返回值类型相同 的效果。这个时候泛型就该登场了,泛型使得代码段的类型定义易于重用(比如我们上面提到的场景又多了一种接收布尔值返回布尔值的场景后的修改),并提升了灵活性与严谨性:

工程层面当然不会写这样的代码了... 但就当个例子看吧hhh

function foo<T>(arg: T): T {
  return arg;
}

我们使用T来表示一个未知的类型,它是入参与返回值的类型,在使用时我们可以显示指定泛型:

foo<string>("linbudu")
const [count, setCount] = useState<number>(1)

当然也可以不指定,因为TS会自动推导出泛型的实际类型。

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

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

如果你在TSX文件中这么写,<T>可能会被识别为JSX标签,因此需要显式告知编译器:

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

除了用在函数中,泛型也可以在类中使用:

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

  public method(): T {
    return this.arg1;
  }
}

泛型除了单独使用,也经常与其他类型编程语法结合使用,可以说泛型就是TS类型编程最重要的基石。单独对于泛型的介绍就到这里(因为单纯的讲泛型实在没有什么好讲的),在接下来我们会讲解更多泛型的高级使用技巧。

索引类型与映射类型

在阅读这一部分前,你需要做好思维转变的准备,需要认识到 类型编程实际也是编程,因此你可以将一部分编程思路复用过来。我们实现一个简单的函数:

// 假设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索引类型查询的语法, 它会返回后面跟着的类型参数的键值组成的字面量类型(literal types),举个例子:

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

type A = keyof foo; // "a" | "b"

字面量类型是对类型的进一步限制,比如你的状态码只可能是0/1/2,那么你就可以写成status: 0 | 1 | 2 的形式。字面量类型包括字符串字面量、数字字面量、布尔值字面量

还少了返回值,如果你此前没有接触过此类语法,应该会卡住,我们先联想下for...in语法,通常遍历对象会这么写:

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

for (const key in fooObj) {
  console.log(key);
  console.log(fooObj[key as keyof foo]);
}

和上面的写法一样,我们拿到了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的键名组成的联合类型(以字面量类型的形式)。具体的知识我们会在下一节条件类型讲到。假设现在我们不只要取出一个值了,我们要取出一系列值:

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][] 它的原理实际上和上面一条相同,之所以单独拿出来是因为我认为它是一个很好地例子:简单的表现了TS类型编程的组合性,你不感觉这种写法就像搭积木一样吗?

索引签名 Index Signature

索引签名用于快速建立一个内部字段类型相同的接口,如

interface Foo {
  [keys: string]: string;
}

那么接口Foo就被认定为字段全部为string类型。值得注意的是,由于JS可以同时通过数字与字符串访问对象属性,因此keyof Foo的结果会是string | number

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

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

但是一旦某个接口的索引签名类型为number,那么它就不能再通过字符串索引访问,如o['1']这样。

映射类型 Mapped Types

映射类型同样是类型编程的重要底层组成,通常用于在旧有类型的基础上进行改造,包括接口包含字段、字段的类型、修饰符(readonly与?)等等。从一个简单场景入手:

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

现在我们有个需求,实现一个接口,它的字段与接口A完全相同,但是其中的类型全部为string,你会怎么做?直接重新声明一个然后手写吗?我们可是聪明的程序员诶,那必不可能这么笨。如果把接口换成对象再想想,其实很简单,new一个新对象,然后遍历A的键名(Object.keys())来填充这个对象。

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

是不是很熟悉?重要的就是这个in操作符,你完全可以把它理解为就是for...in,也就是说你还可以获取到接口键值类型,比如我们复制接口!

type Clone<T> = {
  [K in keyof T]: T[K];
};

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

你可以把工具类型理解为你平时放在utils文件夹下的公共函数,提供了对公用逻辑(在这里则是类型编程逻辑)的封装,比如上面的两个类型接口就是~

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

// 将接口下的字段全部变为可选的
type Partial<T> = {
  [K in keyof T]?: T[k];
};

是不是特别简单,让你已经脱口而出“就这!”,类似的,还可以实现个Readonly,把接口下的字段全部变为只读的。索引类型、映射类型相关的知识我们暂且介绍到这里,要真正理解它们的作用,还需要好好梳理下,建议你看看自己之前项目的类型定义有没有可以优化的地方。

条件类型 Conditional Types

条件类型的语法实际上就是三元表达式:

T extends U ? X : Y

如果你觉得这里的extends不太好理解,可以暂时简单理解为U中的属性在T中都有。

因此条件类型理解起来更直观,唯一需要有一定理解成本的就是 何时条件类型系统会收集到足够的信息来确定类型,也就是说,条件类型有可能不会被立刻完成判断。在了解这一点前,我们先来看看条件类型常用的一个场景:泛型约束,实际上就是我们上面的例子:

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键名的字面量联合类型。我们通常使用泛型约束来**“使得泛型收窄”**。以一个使用条件类型作为函数返回值类型的例子:

declare function strOrnum<T extends boolean>(
  x: T
): T extends true ? string : number;

在这种情况下,条件类型的推导就会被延迟(deferred),因为此时类型系统没有足够的信息来完成判断。只有给出了所需信息(在这里是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";

/*
 * 先分发到 Naked<number> | Naked<boolean>
 * 然后到 "N" | "Y"
 */
type Distributed = Naked<number | boolean>;

/*
 * 不会分发 直接是 [number | boolean] extends [boolean]
 * 然后是"N"
 */
type NotDistributed = Wrapped<number | boolean>; 

现在我们可以来讲讲这几个概念了:

  • 裸类型参数,没有额外被接口/类型别名包裹过的,就像被Wrapped包裹后就不能再被称为裸类型参数。
  • 实例化,其实就是条件类型的判断过程,在这里两个例子的实例化过程实际上是不同的,具体会在下一点中介绍。
  • 分发至联合类型的过程:
  • 对于TypeName,它内部的类型参数T是没有被包裹过的,所以TypeName<string | (() => void)>会被分发为TypeName<string> | TypeName<(() => void)>,然后再次进行判断,最后分发为"string" | "function"
  • 抽象下具体过程:```typescript ( A | B | C ) extends T ? X : Y // 相当于 (A extends T ? X : Y) | (B extends T ? X : Y) | (B extends T ? X : Y)
 复制代码

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

infer关键字

inferinference的缩写,通常的使用方式是infer RR表示 待推断的类型。通常infer不会被直接使用,而是被放置在底层工具类型中,需要在条件类型中使用。看一个简单的例子,用于获取函数返回值类型的工具类型ReturnType:

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

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

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

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

类似的,借着这个思路我们还可以获得函数入参类型、类的构造函数入参类型、Promise内部的类型等,这些工具类型我们会在后面讲到。infer其实没有特别难消化的知识点,它需要的只是思路的转变,你要理解 延迟推断 的概念。

类型守卫 与 is in关键字 Type Guards

前面的内容可能不是那么符合人类直觉,需要一点时间消化,这一节我们来看点简单(相对)且直观的知识点:类型守卫。假设有这么一个字段,它可能字符串也可能是数字:

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);
  }
}

啊哦,看起来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;

是不是还挺有用?这应该是我日常用的最多的类型别名之一了。也可以在in关键字的加持下,进行更强力的类型判断,思考下面这个例子,要如何将 " 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 {
  if ("a" in arg) {
    arg.useA();
  } else {
    arg.useB();
  }
}

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

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;
  }
}

之前有个小哥问过一个问题,我想很多用TS写接口的小伙伴可能都遇到过,即登录与未登录下的用户信息是完全不同的接口:

interface IUserProps {
  isLogin: boolean;
  name: string; // 用户名称仅在登录时有
  from: string; // 用户来源(一般用于埋点),仅在未登录时有
}

这种时候使用字面量类型守卫

function getUserInfo(user: IUnLogin | ILogined): string {
  return user.isLogin ? user.id : user.from;
}

还可以使用instanceof来进行实例的类型守卫,建议聪明的你动手尝试下~

工具类型Tool Type

这一章是本文的最后一部分,应该也是本文“性价比”最高的一部分了,因为即使你还是不太懂这些工具类型的底层实现,也不影响你把它用好。就像Lodash不会要求你每用一个函数就熟知原理一样。这一部分包括TS内置工具类型与社区的扩展工具类型,我个人推荐在完成学习后记录你觉得比较有价值的工具类型,并在自己的项目里新建一个.d.ts文件存储它。

在继续阅读前,请确保你掌握了上面的知识,它们是类型编程的基础

内置工具类型

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

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

它用于将一个接口中的字段变为全部可选,除了映射类型以外,它只使用了?可选修饰符,那么我现在直接掏出小抄(好家伙):

  • 去除可选修饰符:-?
  • 只读修饰符: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];
};

// 期望用法
type Part = Pick<A, "a" | "b">

还是映射类型,只不过现在映射类型的映射源是类型参数K。既然有了Pick,那么自然要有Omit,它和Pick的写法非常像,但有一个问题要解决:我们要怎么表示T中剔除了K后的剩余字段?

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

这里我们又要引入一个知识点:never类型,它表示永远不会出现的类型,通常被用来将收窄联合类型或是接口,详细可以看 [尤大的知乎回答](<https://www.zhihu.com/search?type=content&q=ts never>), 在这里 我们不做展开介绍。上面的场景其实可以简化为:

// "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)

type Exclude<T, U> = T extends U ? never : T;

那么Omit:

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;
};

在前面的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-guide 和 typesafe-actions这两个优秀作品。

我们由浅入深,先封装基础的类型别名和对应的类型守卫,不对原理做讲述:

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内部的实现实际比这个复杂,还考虑了数组的情况,这里为了便于理解做了简化,后面的工具类型也同样存在此类简化。

那么DeepReadobly``DeepRequired也就很简单了:

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 ? DeepMutable<T[P]> : T[P];
};

export type DeepNonNullable<T> = {
  [P in keyof T]: T[P] extends object ? DeepImmutable<T[P]> : NonNullable<T[P]>;
};

返回键名的工具类型

在有些场景下我们需要一个工具类型,它返回接口字段键名组成的联合类型,然后用这个联合类型进行进一步操作(比如给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会保留所有字段,满足条件的字段其键值为字面量类型(值为键名)

加上后面一部分:

// "d"
type UseIt2 = UseIt1[keyof UseIt1]

这个过程类似排列组合: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是必须的,因此空对象{}并不能继承自{ prop: number },而对于可选情况下则可以。因此我们使用这种思路来得到可选/必选的键名。

  • {} extends Pick<T, K>,如果K是可选字段,那么就留下(OptionalKeys,如果是RequiredKeys就剔除)。
  • 怎么剔除?当然是用never了。
export type RequiredKeys<T> = {
  [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干扰,可以理解为就是用于比较的包装,这一层包装能够区分出来只读与非只读属性。
  • 实际使用时(非只读),我们为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得到一个接口中类型为函数的字段。如果你之前没有关注过TS类型编程,那么可能需要一定时间来适应思路的转变。我的建议是,从今天开始,从现在的项目开始,从类型守卫、泛型、最基本的Partial开始,让你的代码精准而优雅

尾声

在结尾说点我个人的理解吧,我认为TypeScript项目实际上是需要经过组织的,而不是这一个接口那一个接口,这里一个字段那里一个类型别名,更别说明明可以使用几个工具类型轻松得到的结果却自己重新写了一遍接口。但很遗憾,要做到这一点实际上会耗费大量精力,并且对业务带来的实质提升是微乎其微的(长期业务倒是还好),毕竟页面不会因为你的类型声明严谨环环相扣就PVUV暴增。我目前的阶段依然停留在寻求开发的效率和质量间寻求平衡,目前的结论:多写TS,写到如臂指使,你的效率就会upup。那我们本篇就到这里了

本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/EWX1oqDctpjoCkYG-aoOUQ

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237231次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8065次阅读
 目录