enum, const enum, as const - Enumeration in TypeScript

enum、const enum 和 as const,应该如何列举资料于 TypeScript 当中?

寻找列举资料的方法

近期重写的专案中有许多状态需要管理,会需要统一管理资料于专案中,为了避免写死代码并且让接手的人都能轻易地了解资料型态,这里记录一些过程中的发现。举例来说目前有个警告程度资料:

const LogLevel = {
DEBUG: 'DEBUG',
WARNING: 'WARNING',
ERROR: 'ERROR',
};

如果我在专案不同地方需要使用到或会接受到这笔资料,要怎么确保「依靠单一资料来源」去提示不同地方会接收到这笔资料呢?

// 我只希望这个函式接收到 LogLevel 这笔资料,而不是其他任何型态
function doSomeThing(level: unknown) {
// Do Something
}

使用列举 enum

由于是 TypeScript 专案,我第一时间想到的是使用列举(Enums)来管理资料,枚举是特殊的「非型别层级」的 TS,用于表示一组常数(不可变的数值)。它有些怪怪的魔法在里面并不是所有人都喜欢,比如说一个简单的 enum:

enum LogLevel {
DEBUG,
WARNING,
ERROR,
}

实际编译出来会是以下这坨东东:

var LogLevel;
(function (LogLevel) {
LogLevel[(LogLevel['DEBUG'] = 0)] = 'DEBUG';
LogLevel[(LogLevel['WARNING'] = 1)] = 'WARNING';
LogLevel[(LogLevel['ERROR'] = 2)] = 'ERROR';
})(LogLevel || (LogLevel = {}));

也就是像是这样对象的效果:

const LogLevel = {
DEBUG: 0
0: 'DEBUG',
WARNING: 1,
1: 'WARNING',
ERROR: 2,
2: 'ERROR',
};

以上是 Numberic Enums🔗 所以通常会使用 String Enums🔗

enum LogLevel {
DEBUG = 'DEBUG',
WARNING = 'WARNING',
ERROR = 'ERROR',
}
function doSomeThing(level: LogLevel) {
// Do Something
}
doSomeThing(LogLevel.DEBUG);

也就是说 Enums 型别是独特的,就算有另一个一样型别定义的 LogLevel 还是会被视为不同型别,这样的语法让通常是 Structural Type 特性的 TS 强化为 Nominal Type 系统的特性,背后 TS 使用了一些魔法抽象但实际上还是在包装一个 JS 对象。

  1. 不是很喜欢 TS 的一些难以预知的魔法转换(如果不定义值会建立成 Numeric Enums,通常是不乐见的)
  2. 型别系统的特性稍微不同,不过我想不是大问题
  3. TypeScript Team 讨论如果能现在重来,大概不会添加这个功能🔗

使用 const enum

这种做法会让 TypeScript 只处理枚举的值在类型上,也就是说并不会有任何 JS 在编译后产生,这听起来很干净且直觉!TS 会直接在编译时将使用到 const enum 的地方替换成对应的值。不过在 TS 官方文件🔗基本上完全不推荐使用 const enum,如果使用在共享代码库中没有办法控制编译器可能会造成问题。

const enum LogLevel {
DEBUG,
WARNING,
ERROR,
}

使用 as const

结果绕了一大圈原来最好的方法就在脚下??介绍老 JS 对象 POJO (Plain Old JavaScript Object),其实就是一个 JS 对象使用 as const 告知 TS 这个对象是完全不可变的,像是 Object.freeze()🔗,不过是深层次并且不存在于运行时,真正意义上不变的值。

如果不熟悉 JS 的特性的话可能会认为 JS 的 const 就意味着声明内容不可变的变量,但实际上这里的不可变意思是指变量不可再被指派新内存地址,也是因为这样的特性 TS 并没有办法确定 const 声明的变量是否为不可变的值。

const logLevel = {
DEBUG: 'DEBUG',
WARNING: 'WARNING',
ERROR: 'ERROR',
};
logLevel.DEBUG = 'WHATEVER'; // 可以被重新指派内容
// 类型:
// const logLevel: {
// DEBUG: string;
// WARNING: string;
// ERROR: string;
// }

但当我们在 TS 中使用 as const 就真的让 TS 知道是不可变的,于是我们能拿到更为明确的类型:

const logLevel = {
DEBUG: 'DEBUG',
WARNING: 'WARNING',
ERROR: 'ERROR',
} as const;
// 类型
// const logLevel: {
// readonly DEBUG: "DEBUG";
// readonly WARNING: "WARNING";
// readonly ERROR: "ERROR";
// }

好耶!也就是说我们可以用 keyoftypeof 来取得这个对象的类型,并且运用在任何地方:

type LogLevel = (typeof logLevel)[keyof typeof logLevel];

甚至制作一个工具类型来帮助我们做转换:

type ObjectValues<T> = T[keyof T];
const logLevel = {
DEBUG: 'DEBUG',
WARNING: 'WARNING',
ERROR: 'ERROR',
} as const;
type LogLevel = ObjectValues<typeof logLevel>;
function doSomeThing(level: LogLevel) {
// Do Something
}
doSomeThing('DEBUG');

抉择

讨论不要使用 Enum 的论点可以总结为:

  • 在编译后成果有点怪异,这些怪异的点会需要特别观察编译结果或阅读文档才能了解
  • Enum 在使用上会更加死板(必须传入 Enum 作为值、相比于 as const 只需要传入对应的值即可)

不过我认为 Enum 也有它的优点:

  • Enum 名称与用途都非常明确
  • 非常死板,所有值只能够过 Enum 输入,确保数据的正确性

完全使用 as const 来达成枚举数据管理,因为它更加直觉没有什么认知负担,并且更加灵活。

延伸阅读