在搁置了大半年以后,我终于回来继续更新这篇文章了。文中内容多数来源于以往学习的文章,这里的修改也会加入一点自己的理解。

泛型 Generic Type

范型在开发中其实还是比较常见的,它对新手来说还是比较难理解的。它的作用比较强大,通俗来讲,它可以被认定为是一个占位符,用于表示一个还未确定的类型。举一个例子来说明一下它的实用场景。

  1. function add(args: number[]): number {
  2. // ...
  3. }

有这样一段代码在项目中(假设),它运行了很久,很多地方都用到了,有一天,出现了新的需求,但函数参数类型不一样,所以可能会被修改成这样一段代码:

  1. function add(args: number[] | string[]): string | number {
  2. // ...
  3. }

(其实以上代码在ts中是会报错的,但这不是重点,重点是我想表示的这个场景)而后,项目需求又发生了一点变化,还是相同的函数,但参数类型不一定,可能是数字数组、字符串数组、字符串数字数组。这时候可能要再去新增一个类型。这么做是没什么问题,但如果项目需求又双叒叕发生了变化的话,函数可能要经历无数次的修改。那么这时候就可以用到范型来表示类型。以上代码可以修改为:

  1. function add<T>(args: T[]): T {
  2. // ...
  3. }

T(不一定就是字母T,可以是任意字母,但大多时候都会只用一个大写字母来表示,而推荐的做法则是在项目达到一定的复杂性后,使用具有更有语义化的单词来作为范型,比如 BasicSchema 这种)则是用来暂时替代类型的占位类型。在使用了范型后,args的类型将不会被静态检查,而是会在当函数运行时再去检查。这时候函数在调用时传入的类型就比较关键,调用时则需要这么写:foo<number>([1,2,3,4])。如果你的值比较简单的时候,ts 还是可以从你的参数传值中推断出来类型的。但如果参数比较复杂的时候自动推导则可能无法正常工作,所以推荐还是设定一下。但如果不想去设定的话,那可以给范型 T 一个默认值,像这样:

  1. function add<T = number>(args: T[]): T {
  2. // ...
  3. }

或者直接一劳永逸,给个 any(更推荐使用 unknown 来替代 any):function add<T = any>(args: T[]): T 。这样在没有传入类型的时候,就会默认被推导为范型参数的默认值。

  1. // 泛型在箭头函数下的书写:
  2. const foo = <T>(arg: T) => arg;
  3. // 如果你在 TSX 文件中这么写,<T>可能会被识别为 JSX 标签,因此需要显式告知编译器:
  4. const foo = <T extends {}>(arg: T) => arg;

范型也不只是在函数中能使用,在 class类中同样可以使用:

  1. class Foo<T, U> {
  2. constructor(public arg1: T, public arg2: U) {}
  3. public method(): T {
  4. return this.arg1;
  5. }
  6. }

索引类型与映射类型

在阅读这一部分前,你需要做好思维转变的准备,需要认识到 类型编程实际也是编程。就像你写业务代码的时候常常会遍历一个对象,而在类型编程中我们也会经常遍历一个接口。因此,你可以将一部分编程思路复用过来。我们实现一个简单的函数:

  1. // 假设key是obj键名
  2. function pickSingleValue(obj, key) {
  3. return obj[key];
  4. }

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

  • 参数obj
  • 参数key
  • 返回值

这三样之间是否存在关联?

  • key必然是obj中的键值名之一,一定为string类型
  • 返回的值一定是obj 中的键值

因此我们初步得到这样的结果:

  1. function pickSingleValue<T>(obj: T, key: keyof T) {
  2. return obj[key];
  3. }

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

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

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

是不是就像 Object.keys()?

字面量类型是对类型的进一步限制,比如你的状态码只可能是 0/1/2,那么你就可以写成status: 0 | 1 | 2的形式。 字面量类型包括字符串字面量数字字面量布尔值字面量。 这一类细碎的基础知识会被穿插在文中各个部分进行讲解,以此避免单独讲解时缺少特定场景让相关概念显得过于单调。

还少了返回值,如果你此前没有接触过此类语法,应该会卡住,我们先联想下 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”之一)。具体的知识我们会在下一节条件类型讲到。
假设现在不只要取出一个值了,我们要取出一系列值,即参数2将是一个数组,成员均为参数1的键名组成:

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

有两个重要变化:

  • keys: U[] 我们知道 U 是 T 的键名组成的联合类型,那么要表示一个内部元素均是 T 键名的数组,就可以使用这种方式,具体的原理请参见下文的 分布式条件类型 章节。
  • T[U][] 它的原理实际上和上面一条相同,首先是T[U],代表参数1的键值(就像Object[Key]),之所以单独拿出来是因为我认为它是一个很好地例子,表现了 TS 类型编程的组合性,你不感觉这种写法就像搭积木一样吗?

索引签名 Index Signature

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

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'],将会抛出 Element implicitly has an 'any' type because index expression is not of type 'number'错误。

映射类型 Mapped Types

映射类型同样是类型编程的重要底层组成,通常用于在旧有类型的基础上进行改造,包括接口包含字段、字段的类型、修饰符(只读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 Clone<T> = {
  [K in keyof T]: T[K];
};

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

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

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

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

条件类型 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;

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

这里面有几个关键词:

  • 裸类型参数
  • 实例化
  • 分发到联合类型 ```typescript // 使用上面的TypeName类型别名

// “string” | “function” type T1 = TypeName void)>;

// “string” | “object” type T2 = TypeName;

// “object” type T3 = TypeName;

在以上例子中,条件类型的推导结果都是联合类型(T3实际上也是,只不过像同所以被合并了,它的结果是两个 `object`类型),而且其实就是类型参数被依次进行条件判断后,再使用 `|` 组合的来的结果。再看个例子:
```typescript
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
    • 抽象一下具体过程:
      ( 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 "test";
};

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

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

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

这个 R并不是一定的,可以自行指定,并且这个参数也不一定就需要声明出来。比如它还可以这么写:

// 使用已声明的 泛型 类型
type ReturnType<T> = T extends (...args: any[]) => infer T ? T : any;
// 使用未声明的 泛型 类型
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
// 使用自定义声明的类型
interface IFoo {
  (): string;
}

type TFoo = () => string;

type ReturnTypes<T> = T extends (...args: any[]) => infer IFoo ? IFoo : any;
type ReturnTypes<T> = T extends (...args: any[]) => infer TFoo ? TFoo : any;

infer没有特别难的地方,只是需要理解一点,就是 延迟推断 的概念。

类型守卫(Type Guards)与 is 关键字

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

testAttr: number | string

如果这种情况非常多,那肯定不能在每个使用的地方挨个判断,所以就可能有了这么一个类型工具判断方法,想试着通过它来缩小这个联合类型的范围,比如精确到 string:

export const isString = (arg: unknown): boolean => typeof arg === "string";

使用它:

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

理想中,它应该是没问题的,确实,在JS中运行是没问题的,但是在TS中会得到这样一段报错:

类型“string | number”上不存在属性“length”。
类型“number”上不存在属性“length”。ts(2339)

这所以工具函数 isString 函数并没有起到收窄类型的作用,参数的类型依然是联合类型,这时候就不能再使用传统函数返回值指定类型的方式了,而是要使用 is 关键字:

export const isString = (arg: unknown): arg is string => typeof arg === "string";

这个时候再去使用,numOrStr变量就会被收窄到 string了。在掌握这个的前提下,我们可以写一个工具类型,判断值是否是一个 falsy 类型的值:

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

export const isFalsy = (val: unknown): val is Falsy => !val;

in 关键词

使用 in 可以进行更强力的类型判断,思考以下例子,怎么将“A | B”的联合类型缩小到 “A”:

class A {
  public a() {}

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

class B {
  public b() {}

  public useB() {
    return "B";
  }
}

function useIt(arg: A | B) {
  if ( ... ) {
    // ...
  } else {
    // ...
  }
}

答案其实就和 JS 中的 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;
  }
}

以上.