08 讲我们介绍了联合和交叉类型,其中有一个使用字面量联合类型来列举可能的类型(间接列举值)的场景,比如说表示星期的类型:

type Day = ‘SUNDAY’ | ‘MONDAY’ | ‘TUESDAY’ | ‘WEDNESDAY’ | ‘THURSDAY’ | ‘FRIDAY’ | ‘SATURDAY’;

const SUNDAY: Day = ‘SUNDAY’;

const SATURDAY: Day = ‘SATURDAY’;

通过这些有着明确含义的单词来定义表示星期几的状态,使得我们的代码更具备可读性。

当然,为了更简洁和高效,我们也可以使用纯数值表示星期几,比如使用 0 到 1 表示从 ‘SUNDAY’ 到 ‘MONDAY’。因为我们真正关注的是星期几这个状态,而不是具体的值,如下代码所示:

type Day = 0 | 1 | 2 | 3 | 4 | 5 | 6;

那有没有一种兼具语义化和简洁值优点的类型呢?在 C/C++/C# 中有能满足这个诉求的类型,它就是枚举(Enums),用来表示一个被命名的整型常数的集合

枚举类型

在 JavaScript 原生语言中并没有与枚举匹配的概念,而 TypeScript 中实现了枚举类型(Enums),这就意味着枚举也是 TypeScript 特有的语法(相对于 JavaScript)。

在 TypeScript 中,我们可以使用枚举定义包含被命名的常量的集合,比如 TypeScript 支持数字、字符两种常量值的枚举类型。

我们也可以使用 enum 关键字定义枚举类型,格式是 enum + 枚举名字 + 一对花括弧,花括弧里则是被命名了的常量成员。

下面我们把前边表示星期的联合类型示例使用枚举类型实现一遍,如下代码所示:

enum Day {

  1. SUNDAY,
  2. MONDAY,
  3. TUESDAY,
  4. WEDNESDAY,
  5. THURSDAY,
  6. FRIDAY,
  7. SATURDAY

}

注意:相对于其他类型,enum 也是一种比较特殊的类型,因为它兼具值和类型于一体,有点类似 class(在定义 class 结构时, 其实我们也自动定义了 class 实例的类型)。

在上述示例中,Day 既可以表示集合,也可以表示集合的类型,所有成员(enum member)的类型都是 Day 的子类型。

前边我们说过,JavaScript 中其实并没有与枚举类型对应的原始实现,而 TypeScript 转译器会把枚举类型转译为一个属性为常量、命名值从 0 开始递增数字映射的对象,在功能层面达到与枚举一致的效果(然而不是所有的特性在 JavaScript 中都有对应的实现)。

下面我们通过如下所示示例看看将如上示例转译为 JavaScript 后的效果。

var Day = void 0;

  1. (function (Day) {
  2. Day[Day["SUNDAY"] = 0] = "SUNDAY";
  3. Day[Day["MONDAY"] = 1] = "MONDAY";
  4. Day[Day["TUESDAY"] = 2] = "TUESDAY";
  5. Day[Day["WEDNESDAY"] = 3] = "WEDNESDAY";
  6. Day[Day["THURSDAY"] = 4] = "THURSDAY";
  7. Day[Day["FRIDAY"] = 5] = "FRIDAY";
  8. Day[Day["SATURDAY"] = 6] = "SATURDAY";
  9. })(Day || (Day = {}));

我们可以看到 Day.SUNDAY 被赋予 0 作为值,Day.SATURDAY 被赋予 6 作为值。

在 TypeScript 中,我们可以通过“枚举名字.常量命名”的格式获取枚举集合里的成员,如下代码所示:

function work(d: Day) {

  1. switch (d) {
  2. case Day.SUNDAY:
  3. case Day.SATURDAY:
  4. return 'take a rest';
  5. case Day.MONDAY:
  6. case Day.TUESDAY:
  7. case Day.WEDNESDAY:
  8. case Day.THURSDAY:
  9. case Day.FRIDAY:
  10. return 'work hard';
  11. }

}

示例中的第 3 行到第 10 行,我们通过 Day.SUNDAY 这样的格式就可以访问枚举的所有成员了。 上面示例中的 work 函数转译为 JavaScript 后,里面的 switch 分支运行时的效果实际上等价于如下所示代码:

  1. switch (d) {
  2. case 0:
  3. case 1:
  4. return 'take a rest';
  5. case 2:
  6. case 3:
  7. case 4:
  8. case 5:
  9. case 6:
  10. return 'work hard';
  11. }
  12. ...

这就意味着在 JavaScript 中调用 work 函数时,传递的参数无论是 enum 还是数值,逻辑上将没有区别,当然这也符合 TypeScript 静态类型检测规则,如下代码所示:

work(Day.SUNDAY); // ok

work(0); // ok

这里我们既可以把枚举成员 Day.SUNDAY 作为 work 函数的入参,也可以把数字字面量 0 作为 work 函数的入参。

下面我们就来详细介绍一下 7 种常见的枚举类型:数字类型、字符串类型、异构类型、常量成员和计算(值)成员、枚举成员类型和联合枚举、常量枚举、外部枚举。


数字枚举

从上边示例可知,在仅仅指定常量命名的情况下,我们定义的就是一个默认从 0 开始递增的数字集合,称之为数字枚举。

如果我们希望枚举值从其他值开始递增,则可以通过“常量命名 = 数值” 的格式显示指定枚举成员的初始值,如下代码所示:

enum Day {

  1. SUNDAY = 1,
  2. MONDAY,
  3. TUESDAY,
  4. WEDNESDAY,
  5. THURSDAY,
  6. FRIDAY,
  7. SATURDAY

}

在上述示例中,我们指定了从 1 开始递增。

事实上,我们可以给 SUNDAY 指定任意类型(比如整数、负数、小数等)、任意起始的数字,其后未显示指定值的成员会递增加 1。上边的示例转译为 JavaScript 之后,则是一个属性值从 1 开始递增的对象,如下代码所示:

var Day = void 0;

  1. (function (MyDay) {
  2. Day[Day["SUNDAY"] = 1] = "SUNDAY";
  3. Day[Day["MONDAY"] = 2] = "MONDAY";
  4. ...
  5. Day[Day["SATURDAY"] = 7] = "SATURDAY";
  6. })(Day || (Day = {}));

这里 Day.SUNDAY 被赋予了 1 作为值,Day.SATURDAY 则被赋予了 7 作为值。

当然我们也可以给任意位置的成员指定值,如下所示示例:

enum Day {

  1. SUNDAY,
  2. MONDAY,
  3. TUESDAY,
  4. WEDNESDAY,
  5. THURSDAY,
  6. FRIDAY,
  7. SATURDAY = 5

}

这里我们给最后一个成员 SATURDAY 指定了初始值 5,但转译后的结果就比较尴尬了,如下代码所示:

  1. Day[Day["FRIDAY"] = 5] = "FRIDAY";
  2. Day[Day["SATURDAY"] = 5] = "SATURDAY";

我们可以看到 MyDay.FRIDAY 和 MyDay.SATURDAY 的值都是数字 5,这就导致使用 Day 枚举作为 switch 分支条件的函数 work,在接收 MyDay.SATURDAY 作为入参时,也会进入 MyDay.FRIDAY 的分支,从而出现逻辑错误。

这个经验告诉我们,由于枚举默认的值自递增且完全无法保证稳定性,所以给部分数字类型的枚举成员**显式指定数值或给函数传递数值**而不是枚举类型作为入参都属于不明智的行为,如下代码所示:

enum Day {

  1. ...
  2. SATURDAY = 5 // bad

}

work(5); // bad

此外,常量命名、结构顺序都一致的两个枚举,即便转译为 JavaScript 后,同名成员的值仍然一样(满足恒等 === )。但在 TypeScript 看来,它们不相同、不满足恒等,如下代码所示:

enum MyDay {

  1. SUNDAY,
  2. ...

}

Day.SUNDAY === MyDay.SUNDAY; // ts(2367) 两个枚举值恒不相等

work(MyDay.SUNDAY); // ts(2345) ‘MyDay.SUNDAY’ 不能赋予 ‘Day’

这里的 MyDay 和上边的 Day 看似一样,但是如果我们拿 MyDay 和 Day 的成员进行比较(第 6 行),或者把 MyDay 传值给形参是 Day 类型的 work 函数(第 7 行),就会发现都会提示错误。

不仅仅是数字类型枚举,**所有其他枚举都仅和自身兼容**,这就消除了由于枚举不稳定性可能造成的风险,所以这是一种极其安全的设计。不过,这可能会使得枚举变得不那么好用,因为不同枚举之间完全不兼容,所以不少 TypeScript 编程人员觉得枚举类型是一种十分鸡肋的类型。而两个结构完全一样的枚举类型如果互相兼容,则会更符合我们的预期,比如说基于 Swagger 自动生成的不同模块中**结构相同且描述同一个常量集合的多个同名枚举**。

不过,此时我们可能不得不使用类型断言(as)或者重构代码将“相同“的枚举类型抽离为同一个公共的枚举(我们更推荐后者)

字符串枚举

在 TypeScript 中,我们将定义值是字符串字面量的枚举称之为字符串枚举,字符串枚举转译为 JavaScript 之后也将保持这些值,我们来看下如下所示示例:

enum Day {

  1. SUNDAY = 'SUNDAY',
  2. MONDAY = 'MONDAY',
  3. ...

}

这里我们定义了成员 SUNDAY 的值是 ‘SUNDAY’、MONDAY 的值是 ‘MONDAY’。

而上述示例转译为 JavaScript 后,Day.SUNDAY 的值依旧是 ‘SUNDAY’,Day.MONDAY 的值依旧是 ‘MONDAY’,如下代码所示:

var Day = void 0;

  1. (function (Day) {
  2. Day["SUNDAY"] = "SUNDAY";
  3. Day["MONDAY"] = "MONDAY";
  4. })(Day || (Day = {}));

相比于没有明确意义的递增值的数字枚举,字符串枚举的成员在运行和调试阶段,更具备明确的含义和可读性,枚举成员的值就是我们显式指定的字符串字面量。


异构枚举(Heterogeneous enums)

从技术上来讲,TypeScript 支持枚举类型同时拥有数字和字符类型的成员,这样的枚举被称之为异构枚举。

当然,异构枚举也被认为是很“鸡肋”的类型。比如如下示例中,我们定义了成员 SUNDAY 是 ‘SUNDAY’、MONDAY 是 2,很抱歉,我也不知道这样的枚举能在哪些有用的场合进行使用。

enum Day {

  1. SUNDAY = 'SUNDAY',
  2. MONDAY = 2,
  3. ...

}

枚举成员的值既可以是数字、字符串这样的常量,也可以是通过表达式所计算出来的值。这就涉及枚举里成员的一个分类,即常量成员和计算成员。

常量成员和计算(值)成员

在前边示例中,涉及的枚举成员的值都是字符串、数字字面量和未指定初始值从 0 递增数字常量,都被称作常量成员。

另外,在转译时,通过被计算的常量枚举表达式定义值的成员,也被称作常量成员,比如如下几种情况:

引用来自预先定义的常量成员,比如来自当前枚举或其他枚举;


圆括弧 () 包裹的常量枚举表达式;


在常量枚举表达式上应用的一元操作符 +、 -、~ ;


操作常量枚举表达式的二元操作符 +、-、*、/、%、<<、>>、>>>、&、|、^。

除以上这些情况之外,其他都被认为是计算(值)成员。

如下所示示例(援引自官方示例)中,除了 G 是计算成员之外,其他都属于常量成员。

enum FileAccess {

  1. // 常量成员
  2. None,
  3. Read = 1 << 1,
  4. Write = 1 << 2,
  5. ReadWrite = Read | Write,
  6. // 计算成员
  7. G = "123".length,

}

注意:关于常量成员和计算成员的划分其实比较难理解,实际上它们也并没有太大的用处,只是告诉我们通过这些途径可以定义枚举成员的值。因此,我们只需记住缺省值(从 0 递增)、数字字面量、字符串字面量肯定是常量成员就够了。


枚举成员类型和联合枚举

另外,对于不需要计算(值)的常量类型成员,即缺省值(从 0 递增)、数字字面量、字符串字面量这三种情况(这就是为什么我们只需记住这三种情况),被称之为字面量**枚举成员**。

前面我们提到枚举值和类型是一体的,枚举成员的类型是枚举类型的子类型

枚举成员和枚举类型之间的关系分两种情况: 如果枚举的成员同时包含字面量和非字面量枚举值,枚举成员的类型就是**枚举本身**(枚举类型本身也是本身的子类型);如果枚举成员全部是字面量枚举值,则所有枚举成员既是值又是类型,如下代码所示:

enum Day {

  1. SUNDAY,
  2. MONDAY,

}

enum MyDay {

  1. SUNDAY,
  2. MONDAY = Day.MONDAY

}

const mondayIsDay: Day.MONDAY = Day.MONDAY; // ok: 字面量枚举成员既是值,也是类型

const mondayIsSunday = MyDay.SUNDAY; // ok: 类型是 MyDay,MyDay.SUNDAY 仅仅是值

const mondayIsMyDay2: MyDay.MONDAY = MyDay.MONDAY; // ts(2535),MyDay 包含非字面量值成员,所以 MyDay.MONDAY 不能作为类型

这里因为 Day 的所有成员都是字面量枚举成员,所以 Day.MONDAY 可以同时作为值和类型使用(第 11 行)。但是 MyDay 的成员 MONDAY 是非字面量枚举成员(但是是常量枚举成员),所以 MyDay.MONDAY 仅能作为值使用(第 12 行 ok,第 13 行提示错误)。

另外,如果枚举仅有一个成员且是字面量成员,那么这个成员的类型等于枚举类型,如下代码所示:

enum Day {

MONDAY

}

export const mondayIsDay: Day = Day.MONDAY; // ok

export const mondayIsDay1: Day.MONDAY = mondayIsDay as Day; // ok

因为枚举 Day 仅包含一个字面量成员 MONDAY,所以类型 Day 和 Day.MONDAY 可以互相兼容。比如第 4 行和第 5 行,我们既能把 Day.MONDAY 类型赋值给 Day 类型,也能把 Day 类型赋值给 Day.MONDAY 类型。

此外,回想 04 讲中介绍的字面量类型特性,不同成员的类型就是不同的字面量类型。纯字面量成员枚举类型也具有字面量类型的特性,也就等价于**枚举的类型将变成各个成员类型组成的联合(枚举)类型。**

联合类型使得 TypeScript 可以更清楚地枚举集合里的确切值,从而检测出一些永远不会成立的条件判断(俗称 Dead Code),如下所示示例(援引自官方恒为真的示例):

enum Day {

  1. SUNDAY,
  2. MONDAY,

}

const work = (x: Day) => {

  1. if (x !== Day.SUNDAY || x !== Day.MONDAY) { // ts(2367)
  2. }

}

在上边示例中,TypeScript 确定 x 的值要么是 Day.SUNDAY,要么是 Day.MONDAY。因为 Day 是纯字面量枚举类型,可以等价地看作联合类型 Day.SUNDAY | Day.MONDAY,所以我们判断出第 7 行的条件语句恒为真,于是提示了一个 ts(2367) 错误。

不过,如果枚举包含需要计算(值)的成员情况就不一样了。如下示例中,TypeScript 不能区分枚举 Day 中的每个成员。因为每个成员类型都是 Day,所以无法判断出第 7 行的条件语句恒为真,也就不会提示一个 ts(2367) 错误。

enum Day {

  1. SUNDAY = +'1',
  2. MONDAY = 'aa'.length,

}

const work = (x: Day) => {

  1. if (x !== Day.SUNDAY || x !== Day.MONDAY) { // ok
  2. }

}

此外,字面量类型所具有的类型推断、类型缩小的特性,也同样适用于字面量枚举类型,如下代码所示:

enum Day {

  1. SUNDAY,
  2. MONDAY,

}

let SUNDAY = Day.SUNDAY; // 类型是 Day

const SUNDAY2 = Day.SUNDAY; // 类型 Day.SUNDAY

const work = (x: Day) => {

  1. if (x === Day.SUNDAY) {
  2. x; // 类型缩小为 Day.SUNDAY
  3. }

}

在上述代码中,我们在第 5 行通过 let 定义了一个未显式声明类型的变量 SUNDAY,TypeScript 可推断其类型是 Day;在第 6 行通过 const 定义了一个未显式声明类型的变量 SUNDAY2,TypeScript 可推断其类型是 Day.SUNDAY;在第 8 行的 if 条件判断中,变量 x 类型也从 Day 缩小为 Day.SUNDAY。

常量枚举(const enums)

枚举的作用在于定义被命名的常量集合,而 TypeScript 提供了一些途径让枚举更加易用,比如常量枚举。

我们可以通过添加 const 修饰符定义常量枚举,常量枚举定义转译为 JavaScript 之后会被移除,并在使用常量枚举成员的地方被替换为相应的内联值,因此常量枚举的成员都必须是常量成员(字面量 + 转译阶段可计算值的表达式),如下代码所示:

const enum Day {

  1. SUNDAY,
  2. MONDAY

}

const work = (d: Day) => {

  1. switch (d) {
  2. case Day.SUNDAY:
  3. return 'take a rest';
  4. case Day.MONDAY:
  5. return 'work hard';
  6. }

}

}

这里我们定义了常量枚举 Day,它的成员都是值自递增的常量成员,并且在 work 函数的 switch 分支里引用了 Day。

转译为成 JavaScript 后,Day 枚举的定义就被移除了,work 函数中对 Day 的引用也变成了常量值的引用(第 3 行内联了 0、第 5 行内联了 1),如下代码所示:

var work = function (d) {

  1. switch (d) {
  2. case 0 /* SUNDAY */:
  3. return 'take a rest';
  4. case 1 /* MONDAY */:
  5. return 'work hard';
  6. }
  7. };

从以上示例我们可以看到,使用常量枚举不仅能减少转译后的 JavaScript 代码量(因为抹除了枚举定义),还不需要到上级作用域里查找枚举定义(因为直接内联了枚举值字面量)。

因此,通过定义常量枚举,我们可以以清晰、结构化的形式维护相关联的常量集合,比如 switch case分支,使得代码更具可读性和易维护性。而且因为转译后抹除了定义、内联成员值,所以在代码的体积和性能方面并不会比直接内联常量值差

外部枚举(Ambient enums)

在 TypeScript 中,我们可以通过 declare 描述一个在其他地方已经定义过的变量,如下代码所示:

declare let $: any;

$(‘#id’).addClass(‘show’); // ok

第 1 行我们使用 declare 描述类型是 any 的外部变量 $,在第 2 行则立即使用 $ ,此时并不会提示一个找不到 $ 变量的错误。

同样,我们也可以使用 declare 描述一个在其他地方已经定义过的枚举类型,通过这种方式定义出来的枚举类型,被称之为外部枚举,如下代码所示:

declare enum Day {

SUNDAY,

MONDAY,

}

const work = (x: Day) => {

if (x === Day.SUNDAY) {

  1. x; // 类型是 Day

}

}

这里我们认定在其他地方已经定义了一个 Day 这种结构的枚举,且 work 函数中使用了它。

转译为 JavaScript 之后,外部枚举的定义也会像常量枚举一样被抹除,但是对枚举成员的引用会被保留(第 2 行保留了对 Day.SUNDAY 的引用),如下代码所示:

var work = function (x) {

  1. if (x === Day.SUNDAY) {
  2. x;
  3. }

};

外部枚举和常规枚举的差异在于以下几点:

在外部枚举中,如果没有指定初始值的成员都被当作计算(值)成员,这跟常规枚举恰好相反;


即便外部枚举只包含字面量成员,这些成员的类型也不会是字面量成员类型,自然完全不具备字面量类型的各种特性。

我们可以一起使用 declare 和 const 定义外部常量枚举,使得它转译为 JavaScript 之后仍像常量枚举一样。在抹除枚举定义的同时,我们可以使用内联枚举值替换对枚举成员的引用。

外部枚举的作用在于为两个不同枚举(实际上是指向了同一个枚举类型)的成员进行兼容、比较、被复用提供了一种途径,这在一定程度上提升了枚举的可用性,让其显得不那么“鸡肋”。

外部枚举一般会出现在类型声明文件(.d.ts)里,用来描述其他地方定义的枚举类型。举个例子,在 types.d.ts 里可以通过 declare enum A { … } 描述在 business.ts 里真正定义的枚举 enum A { … };这样 business.ts 的 enum A 和 types.d.ts 里的 enum A 就可以兼容了。具体可见 codesandbox 示例,https://codesandbox.io/s/typescript-playground-export-forked-5xgn4?file=/business.ts

主要是用在 .d.ts 类型声明文件里(在 .d.ts 里也只能使用外部枚举),在不显式引入定义枚举的模块情况下,就可以直接使用该枚举类型,更多见这篇文章 https://juejin.cn/post/6968820138842062879