TypeScript 类型编程

TypeScript 的类型系统非常强大,它允许用户构建各种自定义类型来应对各种复杂的场景,本文我们将深入探讨 TypeScript 类型编程这一主题。

类型捕获

typeof

可通过 typeof 操作符捕获变量对象属性的类型,比如下面的例子:

let foo = 123;
let bar: typeof foo;
bar = 456;
bar = '789'; // 不能将类型“string”分配给类型“number”。ts(2322)

let o = {
  v: 123,
};
let bar1: typeof o.v;
bar1 = 456;
bar1 = '789'; // 不能将类型“string”分配给类型“number”。ts(2322)
复制代码

编译上述代码,变量 barfoobar1o.v 的类型相同,都是 number;再看下面的例子:

const foo = 123;
let bar: typeof foo; // 'bar' 的类型为字面值类型:123
bar = 456; // 不能将类型“456”分配给类型“123”。ts(2322)
复制代码

编译上述代码,我们会得到不能将类型“456”分配给类型“123” 的错误,这是因为我们将变量 foo 设置为了常量,根据 TypeScript 的类型推倒,变量 foo 的类型为字面量类型 123,此刻除了 123,不能将其它任何值赋予变量 bar,可显式声明 foo 的类型来改变这种行为,比如下面的代码:

const foo: number = 123;
let bar: typeof foo;
bar = 456;
复制代码

再次编译,此刻编译器一路绿灯。这也是在编码过程中大家经常碰到的小问题,知道了缘由,相信大家能够有效地避免与解决类似问题。

keyof

可通过 keyof 关键字提取对象属性名,比如下面的例子:

interface Colors {
  red: string;
  blue: string;
}

let color: keyof Colors; // color 的类型是 'red' | 'blue'
color = 'red'; // ok
color = 'blue'; // ok
color = 'anythingElse'; // ts(2322)
复制代码

上述代码中,变量 color 的类型被限定在 'red' | 'blue',所以我们不能将值 anythingElse 赋予变量 color

泛型

通过将类型参数化,我们可以将多个类型中具有某一共同行为的逻辑进行抽象,以达到代码重用的目的。本文我们仅对 TypeScript 泛型中的类型编程进行阐述,关于泛型的更多信息参见 TypeScript 泛型

extends

可通过 extends 关键字来判断两个类型的父子类型(父子类型的讨论可参见笔者的另一篇文章:TypeScript 类型兼容性),比如下面的例子:

type isSubTyping<Child, Par> = Child extends Par ? true : false;
type isSubNumber = isSubTyping<1, number>; // true
type isSubString = isSubTyping<'string', string>; // true
复制代码

分配条件类型

所谓分配条件类型是指:在使用 extends 关键字对类型进行条件判断时,如果入参是联合类型,那么入参会被拆解为一个个独立的类型来进行类型运算。比如下面的例子:

type BooleanOrString = string | boolean;
type StringOrNumberArray<E> = E extends string | number ? E[] : E;

type BoolOrStringArray = StringOrNumberArray<BooleanOrString>; // boolean | string[]
复制代码

上述代码中,BoolOrStringArray 的类型为 boolean | string[],这是因为按照分配条件类型的规则,我们将 BooleanOrString 拆分成 stringboolean,然后依次作为 StringOrNumberArray参数进行匹配:

  • 因为 string extends string | numbertrue,所以返回 string[]
  • 因为 boolean extends string | numberfalse,所以返回 boolean
  • 最后将前两步的返回值合并,即得到了 string[]|boolean

我们再看下面的例子:

type BooleanOrStringType = BooleanOrString extends string | number ? BooleanOrString[] : BooleanOrString // string | boolean
复制代码

上述代码中,我们通过直接内联 StringOrNumberArray 内部逻辑,而不是通过间接调用 StringOrNumberArray<BooleanOrString> 的方式来定义 BooleanOrStringType,此刻 BooleanOrStringType 的类型变成了 string | boolean,这是因为分配条件类型仅在泛型中有效,在非泛型的情况下,BooleanOrString 将会当作一个整体来参与运算。

infer

在类型(常见于泛型类型)定义中,有时候我们需要根据入参的类型推断出类型定义语句中的某个类型,此时便可使用 infer 关键字来标识这个等待推断的类型变量,比如下面的类型定义:

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

上述代码中,我们定义了一个自动获取参数类型的新类型 ParamType<T>,根据语句定义可知,在 (...args: infer P) => any 的定义中,我们无法获知参数类型 P 的具体类型是什么,因此可通过 infer 来修饰类型 P,以便告诉编译器在入参 T 满足函数定义规则时,自动推导出相关类型。比如下面的例子:

interface Person {
 name: string;
}
type PersonFunc = (person: Person) => void;
type A = ParamType<PersonFunc>; // [person: Person]
type B = ParamType<string>; // string
复制代码

上述代码中,我们可推断出 A 的类型为 [person: Person]B 的类型为 string

索引签名

在进行类型定义时,有时候我们无法穷举该类型的所有属性,又不想将其定义为 any,此时便可使用索引签名来完成类型定义:

interface Messages {
  [type: string]: {
    message: string;
  };
}
复制代码

上述代码中,我们使用 [type: string]type 只是标识符,可替换成你需要的任何名字)来完成 Messages 属性的定义,这种定义类型属性的方式,我们称之为索引签名。通过此方式,既解决了无法穷举类型属性的问题,也解决了使用 any 所引发的安全性降低的问题。比如下面的例子:

let messages: Messages = {};

messages['one'] = { message: 'one message' };
messages['two'] = { msg: 'two message' }; // 不能将类型“{ msg: string; }”分配给类型“{ message: string; }”。对象文字可以只指定已知属性,并且“msg”不在类型“{ message: string; }”中。ts(2322)

console.log(messages['one'].message);
console.log(messages['two'].msg); // 类型“{ message: string; }”上不存在属性“msg”。ts(2339)
复制代码

上述代码中,我们可以为 messages 动态设置属性,并且在属性设置与读取的过程中,都会对属性的值进行安全的类型检测。

索引签名在类型定义中给我们带来了诸多好处,但也需要考虑它与明确成员之间的兼容性,比如下面的例子:

interface Foo {
  [key: string]: number;
  x: string; // 类型“string”的属性“x”不能赋给“string”索引类型“number”。ts(2411)
}
复制代码

上述代码中,我们声明了一个字符串索引签名,以及一个明确的成员 x,编译代码将会抛出 ts(2411) 异常,编译器如此处理的目的是为了保证类型的安全性。因为如果通过索引的形式访问 x 时,谁也无法保证 x 的值转换成 number 后与 x 本身的值相同(即并非所有的 string 都能转换成对应的 number)。

映射类型

映射类型是建立在索引签名的语法上,利用映射类型可基于一个类型来构造出另外一个类型。比如下面的例子:

type AnyType<T> = {
  [key in keyof T]: any;
}
复制代码

AnyType<T> 的定义语句中,我们在索引签名语法的基础上使用了 inkeyof 操作符,也由于通过 AnyType<T> 可以将入参类型 T 构造出一个新的类型 T1,因此 AnyType<T> 可以称为映像类型。比如下面的例子:

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

type AnyPerson = AnyType<Person>;
复制代码

分析上述代码可知 AnyPerson 等同于:

interface AnyPerson {
  name: any;
  age: any;
  address: any;
}
复制代码

需要注意的是,in 只能在类型别名中使用,如果在接口中使用,将抛出异常,比如:

type PersonKeys = 'name' | 'age' | 'address';
interface AnyPerson {
  [key in PersonKeys]: any; // 接口中的计算属性名称必须引用必须引用类型为文本类型或 "unique symbol" 的表达式。ts(1169)
};
复制代码

实战

前面我们讨论了 TypeScript 类型编程所需的前备知识,本节我们通过分析一些常见的类型定义来感受 TypeScript 类型系统的强大。

Exclude

通过 Exclude<T, U> 我们可以排除掉入参 T 中的子类型成员 U

type Exclude<T, U> = T extends U ? never : T;
type A = Exclude<number | string, number>; // string
复制代码

上述代码中, Exclude<T, U> 利用了分配条件类型的特性,根据规则可推断出:

  • A 的类型为 never|string
  • 由于 never 是所有类型的子类型,故可将 never|string 进行类型缩减,最终得到 A 的类型为 string

ReturnType

通过 ReturnType<T> 我们可以获得一个方法的返回值类型:

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

type A = ReturnType<() => string>; // string
复制代码

上述代码中,ReturnType<T> 使用了 infer 关键字,根据规则可推出 A 类型为 string

Required

通过 Required<T> 我们可将入参 T 的所有属性设置为必要属性:

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

interface Person {
  name?: string;
  age?: string;
}

type RequiredPerson = Required<Person>;
复制代码

Required<T> 的定义中,我们使用了映射类型,并且在键值的后面使用了 - 号,-? 的组合表示去除可选属性,因此可推断出 RequiredPerson 的类型相当于:

interface RequiredPerson {
  name: string;
  age: string;
}
复制代码

另外,通过 - 我们亦可去除属性的 readonly 属性:

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

interface ReadonlyPerson {
  readonly name: string;
  readonly age: string;
}

type ChangeablePerson = Changeable<ReadonlyPerson>;
复制代码

其中,ChangeablePerson 相当于:

interface ChangeablePerson {
  name: string;
  age: string;
}
复制代码

Pick

通过 Pick<T, K> 我们可以从入参 T 中取出指定的键值,然后组成一个新的类型:

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

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

type PersonWithoutAge = Pick<Person, 'name' | 'address'>;
复制代码

Pick<T, K> 的定义中,入参 K 的类型被约束为入参 T 的键的子类型,又根据映射类型的规则,故可推断出 PersonWithoutAge 的结果相当于:

interface PersonWithoutAge {
  name: string;
  address: string;
}
复制代码

上面我们分析了一些常用的工具类型,除此之外,TypeScript 内置了许许多多的工具类型,此处不再一一阐述,大家可自行进行分析。

总结

本文我们首先介绍了类型捕获中的 typeof 和 keyof,然后讨论了泛型中的 extends、分配条件类型及 infer,接着对索引签名、映射类型进行了阐述,最后通过实际例子进一步巩固了前面的知识点。通过本文,相信大家对 TypeScript 的类型编程有了较为深入的理解,在以后的实践中,相信大家可以轻松定义出应对各种复杂场景的类型系统,以便为构建强壮、安全的应用提供良好的保障。

参考链接

猜你喜欢

转载自juejin.im/post/7031082611636174884