Négy tagot írsz, nyolc kulcsot kapsz: a TypeScript enum boncolása

Az enum az egyetlen konstrukció a TypeScript magjában, ami fordítás után is ott marad a kódban. Megnéztük, mit generál pontosan, miért veszélyes a const enum publikált csomagban, és miért ad a fordító külön kapcsolót a saját feature-ének kitiltásához.

Gyors kvíz: hány kulcsa van futásidőben annak az objektumnak, amit a TypeScript egy négytagú enumból generál? Aki nyolcat mondott, az vagy nagyon figyelt, vagy már debuggolt olyan kódot, ami Object.keys()-szel iterált egy enumon, és minden ciklus kétszer futott le. A többieknek van egy rossz hírem: az enum nem típusannotáció, hanem kódgenerátor. Az egyetlen konstrukció a TypeScript magjában, ami fordításkor nem eltűnik, hanem JavaScriptet hagy maga után. És 2026-ra ott tartunk, hogy a TypeScript csapata külön fordítókapcsolót ad ki, amivel ki lehet tiltani a saját feature-ét a kódbázisból. Ennél őszintébb termékértékelést nehéz elképzelni.

Diagram: egy négytagú TypeScript enum a fordítás után nyolc kulcsos JavaScript objektummá válik – minden taghoz egy tükörkulcs is készül a reverse mapping miatt

Amit a fordító hátrahagy

Kezdjük a kvíz feloldásával. Ez a négy tag:

enum Irany {
  Fel,
  Le,
  Balra,
  Jobbra,
}

console.log(Object.keys(Irany).length); // 8

fordítás után így néz ki (a cikkhez a júliusban megjelent, Go-ban újraírt TypeScript 7.0-t használtam, de ezt az emitet évtizede változatlanul kapjuk):

var Irany;
(function (Irany) {
    Irany[Irany["Fel"] = 0] = "Fel";
    Irany[Irany["Le"] = 1] = "Le";
    Irany[Irany["Balra"] = 2] = "Balra";
    Irany[Irany["Jobbra"] = 3] = "Jobbra";
})(Irany || (Irany = {}));

A numerikus enum ugyanis reverse mappinget is kap: Irany.Fel értéke 0, de Irany[0] értéke "Fel". Ugyanabban az objektumban él a kettő, ezért lesz négy tagból nyolc kulcs. String enumnál viszont nincs reverse mapping – ott tényleg annyi kulcs van, ahány tag. Vagyis az enum kulcsszó alatt valójában két, futásidőben eltérően viselkedő feature lakik, és ezt az Object.keys-en kívül minden for...in ciklus és minden naiv szerializálás is megérzi.

A típusoldal sem makulátlan

Jó, a runtime csúnya – de legalább a típusellenőrzés vasszigorú, nem? Nos. 2023 márciusáig, a TypeScript 5.0 megjelenéséig ez szó nélkül lefordult:

enum SomeEvenDigit { Zero = 0, Two = 2, Four = 4 }

let m: SomeEvenDigit = 1; // TS 4.9: rendben. TS 5.0 óta: error TS2322

Kipróbáltam a 4.9.5-tel: tényleg lefordul, hibátlanul. Éveken át lehetett numerikus enumnak tetszőleges számot értékül adni, és a fordító bólintott rá. Az 5.0 ezt befoltozta, de egy lyuk máig megmaradt, a legfrissebb fordítóval, strict módban is:

const nev: string = Irany[999]; // típusa: string, hiba nincs
console.log(nev);               // futásidőben: undefined

A reverse lookup típusa feltétel nélkül string, akkor is, ha a keresett szám nem tagja az enumnak. Egy undefined, amiről a típusrendszer esküszik, hogy string – pont az a fajta hazugság, ami ellen a TypeScriptet bevezettük.

A const enum: kevesebb runtime, több csapda

Erre a szokásos válasz a const enum, ami nem generál objektumot, hanem beinline-olja az értékeket. Ez hangzik jól, amíg meg nem nézzük, mit jelent az inline pontosan. Ezt írtam:

// lib/statusz.ts
export const enum Statusz { Aktiv = 1, Torolt = 2 }

// app/main.ts
import { Statusz } from "../lib/statusz.js";
console.log(Statusz.Aktiv);

És ezt kaptam a fordítótól:

console.log(1 /* Statusz.Aktiv */);

Az import eltűnt, a hivatkozásból szám lett. Egy alkalmazáson belül ez működik. De ha a const enum egy publikált csomagból jön, akkor a fordításkor látott A verzió értékei égnek bele a kódodba, miközben futásidőben már a B verzió is lehet telepítve – más értékekkel. A TypeScript kézikönyv külön szakaszban figyelmeztet erre, és arra is, hogy ezek a hibák azért különösen alattomosak, mert a tesztek jellemzően ugyanazokkal a függőségverziókkal futnak, mint a build – vagyis pont a CI nem fogja megfogni. Amikor a nyelv saját dokumentációja „pitfalls” címszó alatt tárgyalja a feature-t, az általában megér egy gondolatot.

Az ökoszisztéma közben döntött

A Node.js type strippingje – ami a 22.18 óta alapértelmezés – a típusokat whitespace-re cseréli, és kész. Az enumot nem tudja kicserélni, mert az nem típus, hanem kód, úgyhogy célzott hibával elhasal:

$ node iranyok.ts
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]:
  TypeScript enum is not supported in strip-only mode

A Node 26 ráadásul eltávolította azt a kísérleti flaget is, ami az enumokat még lefordította – natívan soha nem fognak futni. A TypeScript oldaláról pedig az 5.8 óta létezik az --erasableSyntaxOnly kapcsoló, amivel a fordító már írás közben rászól mindenre, ami nem törölhető szintaxis: enumra, futásidejű namespace-re, parameter property-re. Maga a Node dokumentáció is ezt ajánlja bekapcsolni. Két projekt, két irányból, ugyanaz az üzenet: a TypeScript törölhető része a jövő, az enum pedig nem tartozik bele.

Amiben az enum tényleg jobb

A fair playhez hozzátartozik, hogy egy dolgot az enum tud, amit a típusrendszer más eszköze alapból nem: a string enum nominális. Ez meglepett:

enum LogLevel { Info = "info", Warn = "warn" }

const a: LogLevel = LogLevel.Info; // OK
const b: LogLevel = "info";        // error TS2322!

Hiába egyezik a literál értéke, csak maga az enum-tag adható át. Egy strukturális típusrendszerben ez ritka kincs: ingyen kapunk olyan azonosítókat, amiket nem lehet nyers stringgel hamisítani. A numerikus enum viszont – csak hogy konzisztens legyen a kép – nem nominális: az Irany típusú változónak a 2 literál ma is gond nélkül értékül adható. Ugyanaz a kulcsszó, harmadik féle viselkedés.

Az alternatíva, ami tényleg csak típus

Amit a legtöbb helyen enum helyett érdemes írni, azt maga a kézikönyv is ajánlja „Objects vs Enums” címen:

const Irany = {
  Fel: 0,
  Le: 1,
  Balra: 2,
  Jobbra: 3,
} as const;

type Irany = (typeof Irany)[keyof typeof Irany]; // 0 | 1 | 2 | 3

function lepj(i: Irany) { /* ... */ }
lepj(Irany.Balra);

Az érték és a típus ugyanazt a nevet viseli, a hívási oldal megszólalásig ugyanúgy néz ki, mint enummal. Futásidőben viszont ez egy sima objektum: négy kulcs, nulla generált kód, reverse mapping nélkül. Lefordul --erasableSyntaxOnly-val, és a fenti fájlt a Node 22 közvetlenül, build lépés nélkül lefuttatta nekem. A trade-off: a hibaüzenetekben Irany helyett néha 0 | 1 | 2 | 3 fog szerepelni, ami hosszabb unionoknál kifejezetten ronda tud lenni, és a nominalitásról is lemondunk – ha az kell, marad a string enum vagy egy branded type.

Mit csinálnék éles kódban

A const enumot publikált csomagban semmilyen körülmények között nem használnám – a verzió-skew olyan hiba, amit a CI definíció szerint nem lát. A numerikus enumot új kódban szintén nem: a reverse mapping meglepetései többe kerülnek, mint amennyit a szintaxis kényelme ér. A string enum a védhető kivétel – nincs reverse mappingje, és a nominalitása valódi, máshol drágán megszerezhető garancia. Meglévő kódbázisban eszem ágában sem lenne enumokat vadászni; ez pont az a refaktor, aminek a kockázata nagyobb, mint a haszna. De új projektet ma már erasableSyntaxOnly-val indítanék, as const objektumokkal – és akkor a kérdés fel sem merül. Az enum a TypeScript legkorábbi éveinek öröksége, abból az időből, amikor a nyelv még nyelvnek képzelte magát, nem típusrétegnek. A JavaScript azóta sem vette át, a tooling kinőtte, és a fordító már kapcsolót ad hozzá, hogy udvariasan nemet mondhassunk rá.

Források