enum、const enum 和 as const,應該如何列舉資料於 TypeScript 當中?
尋找列舉資料的方法
近期重寫的專案中有許多狀態需要管理,會需要統一管理資料於專案中,為了避免寫死代碼(Hard Coded)並且讓接手的人都能輕易地瞭解資料型態,這裡記錄一些過程中的發現。舉例來說目前有個警告程度資料:
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 物件。
- 不是很喜歡 TS 的一些難以預知的魔法轉換(如果不定義值會建立成 Numeric Enums,通常是不樂見的)
- 型別系統的特性稍微不同,不過我想不是大問題
- 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";// }
好耶!也就是說我們可以用 keyof
和 typeof
來取得這個物件的型別,並且運用在任何地方:
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
來達成列舉資料管理,因為它更加直覺沒有什麼認知負擔,並且更加靈活。
延伸閱讀
- Enums - TypeScript
- Enums considered harmful - Matt Pocock
- The TRUTH About TypeScript Enums - James Q Quick
- as const: the most underrated TypeScript feature - Matt Pocock
- TypeScript Enums are TERRIBLE. Here’s Why. - Michigan TypeScript