在这篇博文中,我们将更仔细地研究 TypeScript enums:

  • 它们是如何工作的?
  • 它们的用例是什么?
  • 如果我们不想使用它们,有哪些替代方案?

这篇博文最后总结了关于何时使用什么方案的建议。

本文档中使用的符号

为了在源代码中显示推断的类型,我使用了 npm 包 ts-expect – 例如:

// 值的类型
expectType<string>('abc');
expectType<number>(123);

// 类型的相等性
type Pair<T> = [T, T];
expectType<TypeEqual<
  Pair<string>, [string,string]
>>(true);

TypeScript Enum 的基础知识

在各种编程语言中,有许多不同种类的 enums。在 TypeScript 中,enum 定义了两个东西:

  • 一个将成员键映射到成员值的对象
  • 一个只包含成员值的类型

请注意,在这篇博文中我们忽略了 const enums

接下来,我们将更详细地了解 enums 的各个方面。

Enum 定义了一个对象

一方面,enum 是一个将成员键映射到成员值的对象。在这方面,它的工作方式很像对象字面量:

enum Color {
  Red = 0,
  Green = 'GREEN',
}
assert.equal(Color.Red, 0);
assert.equal(Color.Green, 'GREEN');
assert.equal(Color['Green'], 'GREEN');

一个限制是只有数字和字符串可以作为成员值。

Enum 定义了一个类型

另一方面,enum 也是一个只包含成员值的类型:

let color: Color;
color = Color.Red;
color = Color.Green;
// @ts-expect-error: 类型“true”不可分配给类型“Color”。
color = true;

字符串成员和数字成员之间有一个重要的区别:我们不能将纯字符串赋值给 color

// @ts-expect-error: 类型“"GREEN"”不可分配给
// 类型“Color”。
color = 'GREEN';

但是我们可以给 color 赋值一个纯数字——如果它是成员的值:

color = 0;
// @ts-expect-error: 类型“123”不可分配给类型“Color”。
color = 123;

我们可以为 enums 检查穷尽性

考虑以下 enum:

enum Color {
  Red = 0,
  Green = 'GREEN',
}

如果我们处理类型为 Color 的变量可能具有的值,那么如果我们忘记了其中一个值,TypeScript 可以警告我们。换句话说:如果我们没有“穷尽地”处理所有情况。这被称为 穷尽性检查。为了了解它是如何工作的,让我们从以下代码开始:

// @ts-expect-error: 不是所有代码路径都返回值。
function colorToString(color: Color) {
  expectType<Color>(color); // (A)
  if (color === Color.Red) {
    return 'red';
  }
  expectType<Color.Green>(color); // (B)
  if (color === Color.Green) {
    return 'green';
  }
  expectType<never>(color); // (C)
}

在 A 行,color 仍然可以有任何值。在 B 行,我们已经排除了 Color.Redcolor 只能有值 Color.Green。在 C 行,color 不能有任何值——这解释了它的类型 never

如果 color 在 C 行 不是 never,那么我们就忘记了一个成员。我们可以让 TypeScript 在编译时报告错误,如下所示:

function colorToString(color: Color) {
  if (color === Color.Red) {
    return 'red';
  }
  if (color === Color.Green) {
    return 'green';
  }
  throw new UnsupportedValueError(color);
}

这是如何工作的?我们传递给 UnsupportedValueErrorvalue 必须具有类型 never

class UnsupportedValueError extends Error {
  constructor(value: never, message = `不支持的值: ${value}`) {
    super(message)
  }
}

如果我们忘记了第二种情况,就会发生这种情况:

function colorToString(color: Color) {
  if (color === Color.Red) {
    return 'red';
  }
  // @ts-expect-error: 类型“Color.Green”的参数
  // 不可分配给类型“never”的参数。
  throw new UnsupportedValueError(color);
}

穷尽性检查在 case 语句中也同样有效:

function colorToString(color: Color) {
  switch (color) {
    case Color.Red:
      return 'red';
    case Color.Green:
      return 'green';
    default:
      throw new UnsupportedValueError(color);
  }
}

另一种检查穷尽性的方法是为函数指定返回类型:

// @ts-expect-error: 函数缺少结尾返回语句,并且
// 返回类型不包含“undefined”。
function colorToString(color: Color): string {
  switch (color) {
    case Color.Red:
      return 'red';
  }
}

在我的代码中,我通常会这样做,但也会抛出一个 UnsupportedValueError,因为我喜欢有一个在运行时也有效的检查。

枚举成员

偶尔有用的一个操作是枚举 enum 的成员。我们可以在 TypeScript enums 中做到这一点吗?让我们使用之前的 enum:

enum Color {
  Red = 0,
  Green = 'GREEN',
}

编译成 JavaScript 后,Color 看起来像这样:

var Color;
(function (Color) {
    Color[Color["Red"] = 0] = "Red"; // (A)
    Color["Green"] = "GREEN"; // (B)
})(Color || (Color = {}));

这是一个立即调用的函数,它向对象 Color 添加属性。

B 行中字符串成员 Green 的代码很简单:它从键映射到值。

A 行中数字成员 Red 的代码为 Red 添加了两个属性而不是一个——从键到值的映射和从值到键的映射:

Color["Red"] = 0;
Color[0] = "Red";

请注意,第二行中的零被强制转换为字符串(属性键只能是字符串或符号)。因此,我们甚至无法真正以这种方式查找数字。我们必须先将其转换为字符串。

因此,数字成员阻止我们枚举此 enum 的键或值:

assert.deepEqual(
  Object.keys(Color), ['0', 'Red', 'Green']
);
assert.deepEqual(
  Object.values(Color), ['Red', 0, 'GREEN']
);

如果我们切换到仅字符串成员,则枚举有效:

enum Color {
  Red = 'RED',
  Green = 'GREEN',
}

assert.deepEqual(
  Object.keys(Color), ['Red', 'Green']
);
assert.deepEqual(
  Object.values(Color), ['RED', 'GREEN']
);

没有显式指定值的 Enums

我们还可以创建没有显式指定成员值的 enums。然后 TypeScript 会为我们指定它们并使用数字:

enum Color {
  Red, // 隐式 = 0
  Green, // 隐式 = 1
}
assert.equal(Color.Red, 0);
assert.equal(Color.Green, 1);

Enums 的用例

理解 enums 和 enum 相关模式可能会很快变得混乱,因为 enum 的含义在不同的编程语言之间差异很大——例如:

  • Java 的 enums 是具有固定实例集的类。
  • Rust 的 enums 更像函数式编程语言中的代数数据类型。它们与 TypeScript 中的可辨识联合松散相关。

因此,我们受益于对术语 enum 的狭义定义:

  • 一组固定的值。
  • 可以通过对象的键访问。

在以下章节中,我们将介绍这种 enum 的以下用例:

  1. 具有原始值的常量的命名空间
  2. 具有唯一值的自定义类型
  3. 具有对象值的常量的命名空间

我们将考虑 TypeScript enums 在这些用例中的效果如何。(剧透:它们在 #1 和 #2 中效果相当好,但不能用于 #3。)我们将研究我们可以使用的类似 enum 的模式来代替。

对于每个选项,我们还将检查:

  • 可以执行穷尽性检查吗?
  • 可以枚举成员吗?

用例:具有原始值的常量的命名空间

有时使用 enums(或类似 enum 的对象)的一种方式只是作为常量的命名空间——例如,Node.js 函数 fs.access() 有一个参数 mode,其值通过类似于以下 enum 的对象提供:

enum constants {
  F_OK = 0,
  R_OK = 4,
  W_OK = 2,
  X_OK = 1,
  // ...
}

除了第一个值之外,这些是通过按位或组合的位:

const readOrWrite = constants.R_OK | constants.W_OK;

Enum 作为具有原始值的常量的命名空间

哪些 enum 特性与此用例相关?

  • enums 的一个主要限制是值只能是数字或字符串。
  • enum 作为类型并不重要:参数 mode 的类型是 number,而不是 constants 或类似的东西。这是因为 constants 的值不是 mode 所有可能值的穷举列表。
  • 出于同样的原因,穷尽性检查在这种情况下不相关。
  • 枚举成员也不是理想的。而且这也不是我们可以用数值 enum 做的事情。

Enum 的替代方案:对象字面量

对于此用例,对象字面量是一个非常好的替代方案:

const constants = {
  __proto__: null,
  F_OK: 0,
  R_OK: 4,
  W_OK: 2,
  X_OK: 1,
};

我们使用伪属性键 __proto__constants 的原型设置为 null。这是一个很好的实践,因为这样我们就不必处理继承的属性:

  • 主要好处是我们可以使用 in 运算符来检查 constants 是否具有给定的键,而无需担心从 Object.prototype 继承的属性,例如 .toString
  • 但是,Object.keys()Object.values() 无论如何都会忽略继承的属性,因此我们不会在那里获得任何好处。

请注意,__proto__ 也作为 Object.prototype 中的 getter 和 setter 存在。此功能已被弃用,取而代之的是 Object.getPrototypeOf()Object.setPrototypeOf()。但是,这与在对象字面量中使用此名称不同——后者并未被弃用。

有关更多信息,请查看“Exploring JavaScript”的以下章节:

用例:具有唯一值的自定义类型

有时我们可能想要定义我们自己的自定义类型,该类型具有一组固定的值。例如,布尔值并不总是能很好地表达意图。那么 enum 可以做得更好:

enum Activation {
  Active = 'Active',
  Inactive = 'Inactive',
}

通过 = 显式指定字符串值是一个好习惯:

  • 我们获得了更高的类型安全性,并且不会意外地在期望 Activation 的地方提供数字。
  • 我们可以枚举 Activation 的键和值。

Enum 作为具有唯一值的自定义类型

哪些 enum 特性与此用例相关?

  • 我们将使用 Activation 定义的类型。
  • 穷尽性检查是可能的且有用的。
  • 我们也可能想要枚举键或值。

Enum 的替代方案:对象字面量

让我们使用对象字面量来定义 enum 的值部分(我们稍后会介绍类型部分):

const Activation = {
  __proto__: null,
  Active: 'Active',
  Inactive: 'Inactive',
} as const; // (A)

// 如果没有 `as const`,此类型将为 `string`:
expectType<'Active'>(Activation.Active);

type ActivationType = PropertyValues<typeof Activation>;
expectType<
  TypeEqual<ActivationType, 'Active' | 'Inactive'>
>(true);

A 行中的 as const 使我们能够通过辅助类型 PropertyValues(在下面定义)从 Activation 派生 ActivationType

为什么此类型称为 ActivationType 而不是 Activation?由于值和类型的命名空间在 TypeScript 中是分开的,因此我们确实可以使用相同的名称。但是,当使用 Visual Studio Code 重命名值和类型时,我遇到了问题:它变得混乱,因为导入 Activation 会同时导入值和类型。这就是为什么我暂时使用不同的名称。

辅助类型 PropertyValues 看起来像这样:

type PropertyValues<Obj> = Obj[Exclude<keyof Obj, '__proto__'>];
  • 类型 Obj[K] 包含所有属性的值,这些属性的键都在 K 中。
  • 我们从 keyof Obj 中排除键 '__proto__',因为 TypeScript 将该键视为普通属性,而这并不是我们想要的(相关的 GitHub 问题)。

让我们探索一下如果我们不使用 as const,派生类型会是什么样子:

const Activation = {
  __proto__: null,
  Active: 'Active',
  Inactive: 'Inactive',
};

expectType<string>(Activation.Active);
expectType<string>(Activation.Inactive);

type ActivationType = PropertyValues<typeof Activation>;
expectType<
  TypeEqual<ActivationType, string>
>(true);

穷尽性检查

TypeScript 支持字符串字面量类型联合的穷尽性检查。而这正是 ActivationType。因此,我们可以使用与 enums 相同的模式:

function activationToString(activation: ActivationType): string {
  switch (activation) {
    case Activation.Active:
      return 'ACTIVE';
    case Activation.Inactive:
      return 'INACTIVE';
    default:
      throw new UnsupportedValueError(activation);
  }
}

枚举成员

我们可以使用 Object.keys()Object.values() 来枚举对象 Activation 的成员:

for (const value of Object.values(Activation)) {
  console.log(value);
}

输出:

Active
Inactive

使用 symbols 作为属性值

使用字符串作为属性值的一个缺点是 ActivationType 不排除使用任意字符串。如果我们使用 symbols,我们可以获得更高的类型安全性:

const Active = Symbol('Active');
const Inactive = Symbol('Inactive');

const Activation = {
  __proto__: null,
  Active,
  Inactive,
} as const;

expectType<typeof Active>(Activation.Active);

type ActivationType = PropertyValues<typeof Activation>;
expectType<
  TypeEqual<
    ActivationType, typeof Active | typeof Inactive
  >
>(true);

这似乎过于复杂:为什么在我们在对象字面量中使用 symbols 之前,要先声明 symbols 的中间步骤?为什么不在对象字面量内部创建 symbols?唉,这是当前 as const 对于 symbols 的一个限制:它们未被识别为唯一(相关的 GitHub 问题):

const Activation = {
  __proto__: null,
  Active: Symbol('Active'),
  Inactive: Symbol('Inactive'),
} as const;

// 唉,Activation.Active 的类型不是 `typeof Active`
expectType<symbol>(Activation.Active);

type ActivationType = PropertyValues<typeof Activation>;
expectType<
  TypeEqual<ActivationType, symbol>
>(true);

Enum 的替代方案:字符串字面量类型的联合

当涉及到定义具有固定成员集的类型时,字符串字面量类型的联合是 enum 的一个有趣的替代方案:

type Activation = 'Active' | 'Inactive';

这种类型与 enum 相比如何?

优点:

  • 这是一个快速而简单的解决方案。
  • 它支持穷尽性检查。
  • 在 Visual Studio Code 中重命名成员效果相当好。

缺点:

  • 类型成员不是唯一的。我们可以通过使用 symbols 来更改这一点,但那样我们就会失去字符串字面量联合类型的一些便利性——例如,我们将不得不导入值。
  • 我们无法枚举成员。下一节将解释如何更改这一点。

通过 Sets 实体化字符串字面量联合

实体化 意味着为元级别(想想 TypeScript 类型)存在的实体在对象级别(想想 JavaScript 值)创建一个实体。

我们可以使用 Set 来实体化字符串字面量联合类型:

const activation = new Set([
  'Active',
  'Inactive',
] as const);
expectType<Set<'Active' | 'Inactive'>>(activation);

// @ts-expect-error: 类型“"abc"”的参数不可分配给
// 类型“"Active" | "Inactive"”的参数。
activation.has('abc');
  // 自动完成适用于 .has()、.delete() 等的参数。

// 让我们将 Set 转换为字符串字面量联合
type Activation = SetElementType<typeof activation>;
expectType<
  TypeEqual<Activation, 'Active' | 'Inactive'>
>(true);

type SetElementType<S extends Set<any>> =
  S extends Set<infer Elem> ? Elem : never;

用例:具有对象值的常量的命名空间

有时,拥有一个类似 enum 的结构来查找更丰富的数据(存储在对象中)很有用。我们不能使用对象作为 enum 值,因此我们将不得不使用其他解决方案。

属性值为对象的对象字面量

这是使用对象字面量作为对象 enum 的示例:

// 此类型是可选的:它约束了 `TextStyle` 的属性值
// 但没有其他用途。
type TTextStyle = {
  key: string,
  html: string,
  latex: string,
};
const TextStyle = {
  Bold: {
    key: 'Bold',
    html: 'b',
    latex: 'textbf',
  },
  Italics: {
    key: 'Italics',
    html: 'i',
    latex: 'textit',
  },
} as const satisfies Record<string, TTextStyle>;

type TextStyleType = PropertyValues<typeof TextStyle>;
type PropertyValues<Obj> = Obj[Exclude<keyof Obj, '__proto__'>];

穷尽性检查

为什么 TextStyle 的属性值具有属性 .key?该属性使我们能够进行穷尽性检查,因为属性值形成可辨识联合

function f(textStyle: TextStyleType): string {
  switch (textStyle.key) {
    case TextStyle.Bold.key:
      return 'BOLD';
    case TextStyle.Italics.key:
      return 'ITALICS';
    default:
      throw new UnsupportedValueError(textStyle); // 没有 `.key`!
  }
}

为了比较,如果 TextStyle 是一个 enum,f() 会是什么样子:

enum TextStyle2 { Bold, Italics }
function f2(textStyle: TextStyle2): string {
  switch (textStyle) {
    case TextStyle2.Bold:
      return 'BOLD';
    case TextStyle2.Italics:
      return 'ITALICS';
    default:
      throw new UnsupportedValueError(textStyle);
  }
}

Enum 类

我们还可以使用类作为 enum——一种从 Java 借用的模式:

class TextStyle {
  static Bold = new TextStyle(/*...*/);
  static Italics = new TextStyle(/*...*/);
}
type TextStyleKeys = EnumKeys<typeof TextStyle>;
expectType<
  TypeEqual<TextStyleKeys, 'Bold' | 'Italics'>
>(true);

type EnumKeys<T> = Exclude<keyof T, 'prototype'>;

这种模式的一个优点是我们可以使用方法向 enum 值添加行为。一个缺点是没有简单的方法来进行穷尽性检查。

Object.keys()Object.values() 忽略 TextStyle 的非枚举属性,例如 .prototype——这就是为什么我们可以使用它们来枚举键和值——例如:

assert.deepEqual(
  // TextStyle.prototype 是不可枚举的
  Object.keys(TextStyle),
  ['Bold', 'Italics']
);

Enum 的映射与反向映射

有时我们想要将 enum 值转换为其他值,反之亦然——例如,在将它们序列化为 JSON 或从 JSON 反序列化它们时。如果我们通过 Map 这样做,如果忘记了 enum 值,我们可以使用 TypeScript 来获取警告。

为了探索它是如何工作的,我们将使用以下 enum 模式类型:

const Pending = Symbol('Pending');
const Ongoing = Symbol('Ongoing');
const Finished = Symbol('Finished');
const TaskStatus = {
  __proto__: null,
  Pending,
  Ongoing,
  Finished,
} as const;
type TaskStatusType = PropertyValues<typeof TaskStatus>;
type PropertyValues<Obj> = Obj[Exclude<keyof Obj, '__proto__'>];

这是 Map:

const taskPairs = [
  [TaskStatus.Pending, 'not yet'],
  [TaskStatus.Ongoing, 'working on it'],
  [TaskStatus.Finished, 'finished'],
] as const;

type Key = (typeof taskPairs)[number][0];
const taskMap = new Map<Key, string>(taskPairs);

如果您想知道为什么我们没有直接使用 taskPairs 的值作为 new Map() 的参数并省略类型参数:如果键是 symbols,TypeScript 似乎无法推断类型参数并报告编译时错误。使用字符串,代码会更简单:

const taskPairs = [
  ['Pending', 'not yet'],
  ['Ongoing', 'working on it'],
  ['Finished', 'finished'],
] as const;
const taskMap = new Map(taskPairs); // 没有类型参数!

最后一步是检查我们是否只忘记了 TaskStatus 的值:

expectType<
  TypeEqual<MapKey<typeof taskMap>, TaskStatusType>
>(true);
type MapKey<M extends Map<any, any>> =
  M extends Map<infer K, any> ? K : never;

建议

我们应该何时使用 enum,何时使用替代模式?

TypeScript enums 不是 JavaScript: Enums 是少数 TypeScript 语言结构(相对于类型结构)之一,它们没有对应的 JavaScript 功能。这可能在两个方面很重要:

  • 转译后的代码看起来有点奇怪——尤其是一些 enum 成员是数字时。
  • 如果一个工具不转译 TypeScript 而只是剥离类型,那么它将不支持 enums。这(还?)不常见,但一个突出的例子是 Node 当前对 TypeScript 的内置支持

字符串的性能: 需要记住的一件事是,比较字符串通常比比较数字或 symbols 慢。因此,值是字符串的 enums 或 enum 模式会更慢。请注意,这也适用于字符串字面量联合。但是,只有当我们进行大量比较时,这种性能成本才重要。

用例是什么? 查看用例可以帮助我们做出决定:

  1. 具有原始值的常量的命名空间:
    • 如果原始值是数字或字符串,我们可以使用 TypeScript enum。
      • 唉,数字值不太好,因为每个成员都会产生两个属性:从键到值的映射和反向映射。
    • 否则(或者如果我们不想使用 enum),我们可以使用对象字面量。
  2. 具有唯一值的自定义类型:
    • 如果我们使用 enum,它应该具有字符串值,因为这为我们提供了更高的类型安全性,并允许我们迭代键和值。
    • 字符串字面量类型的联合是一种轻量级、快速的解决方案。它的缺点是:类型安全性较低,并且没有命名空间对象用于轻松查找。
      • 如果我们想在运行时访问字符串字面量值,我们可以使用 Set 来实体化它们。
    • 如果我们想要一个可靠、稍微冗长的解决方案,我们可以使用带有 symbol 属性值的对象字面量。
  3. 具有对象值的常量的命名空间:
    • 我们不能将 enums 用于此用例。
    • 我们可以使用属性值为对象的对象字面量。此解决方案的优点是我们可以检查穷尽性。
    • 如果我们希望 enum 值具有方法,我们可以使用 enum 类。然而,这意味着我们无法检查穷尽性。