12 - Enums vs unions litterales : et pourquoi je déconseillé les enums
Ce que tu vas apprendre
- Comment fonctionnent les enums en TypeScript (et ce qu'ils generent au runtime)
- Pourquoi les unions litterales sont plus simples et plus previsibles
- Le pattern
as const+typeofpour remplacer les enums - Les rares cas ou un enum se justifie
Prerequisites
Avoir lu l'article sur les unions et intersections.
Le code généré qui m'a surpris
La première fois que j'ai regarde le JavaScript généré par un enum TypeScript, j'ai ete surpris. J'avais écrit ca :
typescriptenum Status {
Active = "active",
Inactive = "inactive",
Banned = "banned"
}
Et TypeScript avait généré ca :
javascriptvar Status;
(function (Status) {
Status["Active"] = "active";
Status["Inactive"] = "inactive";
Status["Banned"] = "banned";
})(Status || (Status = {}));
Un IIFE, un objet mutable, du code runtime. Pour trois strings. Une union litterale produit zero code au runtime :
typescripttype Status = "active" | "inactive" | "banned"
Ca disparaît complètement a la compilation. Pas de code généré. Pas d'objet. Pas de poids dans le bundle.
Les enums string
Les enums string sont les plus courants :
typescriptenum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT"
}
function move(dir: Direction) {
// ...
}
move(Direction.Up) // ✅
move("UP") // ❌ Argument of type '"UP"' is not assignable to type 'Direction'
Le dernier point est un problème. L'enum créé un type nominal : la valeur "UP" n'est pas Direction.Up pour le compilateur, meme si au runtime c'est la meme string. Ca complique l'interopérabilité avec des donnees JSON (API, base de donnees, fichiers de config) ou la valeur est une string brute.
typescriptconst apiResponse = { status: "active" } // string brute depuis l'API
function process(status: Status) { /* ... */ }
process(apiResponse.status) // ❌ string n'est pas Status
// Il faut caster
process(apiResponse.status as Status) // fonctionne mais unsafe
Avec une union litterale, pas de problème :
typescripttype Status = "active" | "inactive" | "banned"
const apiResponse = { status: "active" as const }
process(apiResponse.status) // ✅
Les enums numériques : le piège
Les enums numériques sont pires. Par défaut, TypeScript assigne des valeurs auto-incrementees :
typescriptenum Priority {
Low, // 0
Medium, // 1
High // 2
}
Le problème : le reverse mapping. TypeScript généré un objet bidirectionnel :
javascriptvar Priority;
(function (Priority) {
Priority[Priority["Low"] = 0] = "Low";
Priority[Priority["Medium"] = 1] = "Medium";
Priority[Priority["High"] = 2] = "High";
})(Priority || (Priority = {}));
Ca veut dire que Priority[0] retourne "Low" et Priority["Low"] retourne 0. Et le type Priority accepte n'importe quel number :
typescriptenum Priority { Low, Medium, High }
const p: Priority = 999 // ✅ compile — aucune erreur
999 n'est ni Low, ni Medium, ni High. Mais le compilateur accepte. C'est un trou dans le système de types qui existe depuis les debuts de TypeScript et qui n'a jamais ete corrige pour des raisons de retrocompatibilite.
const enum : un faux ami
TypeScript a const enum qui inline les valeurs au lieu de générer un objet :
typescriptconst enum Color {
Red = "red",
Green = "green",
Blue = "blue"
}
const c = Color.Red // compile en : const c = "red"
Zero code généré a part la valeur brute. Ca semble ideal. Sauf que const enum a des problèmes :
- Incompatible avec
isolatedModules(utilise par Babel, esbuild, swc, Vite) - Incompatible avec
--declarationdans certains cas - Les valeurs sont inlinees au moment du build — si une lib exporte un
const enumet change les valeurs, le code consommateur garde les anciennes valeurs jusqu'au prochain build - La team TypeScript elle-meme déconseillé les const enum
L'alternative : unions litterales
typescripttype Status = "active" | "inactive" | "banned"
Avantages :
- Zero code runtime
- Compatible avec les donnees JSON brutes
- Compatible avec tous les build tools
- Le compilateur vérifié l'exhaustivite
- Autocompletion dans l'IDE
typescriptfunction getLabel(status: Status): string {
switch (status) {
case "active": return "Actif"
case "inactive": return "Inactif"
case "banned": return "Banni"
}
}
Meme exhaustivite check que les enums (combine avec never comme dans l'article 01).
Le pattern as const + typeof
Si tu veux un objet avec les valeurs (pour itérer, pour avoir un mapping), utilise as const :
typescriptconst STATUS = {
Active: "active",
Inactive: "inactive",
Banned: "banned"
} as const
type Status = typeof STATUS[keyof typeof STATUS]
// "active" | "inactive" | "banned"
Tu as le meilleur des deux mondes : un objet itérable au runtime et un type union au niveau du compilateur.
typescript// Iteration sur les valeurs
Object.values(STATUS).forEach(s => console.log(s))
// Mapping label
const LABELS: Record<Status, string> = {
active: "Actif",
inactive: "Inactif",
banned: "Banni"
}
// Type-safe dans les fonctions
function process(status: Status) { /* ... */ }
process("active") // ✅ accepte les strings brutes
Extraire le type proprement
Le pattern typeof OBJ[keyof typeof OBJ] est verbeux. Tu peux créer un type utilitaire :
typescripttype ValueOf<T> = T[keyof T]
const STATUS = { Active: "active", Inactive: "inactive", Banned: "banned" } as const
type Status = ValueOf<typeof STATUS> // "active" | "inactive" | "banned"
Arrays avec as const
Pour une liste simple de valeurs :
typescriptconst ROLES = ["admin", "editor", "viewer"] as const
type Role = typeof ROLES[number] // "admin" | "editor" | "viewer"
// Iterable
ROLES.forEach(role => console.log(role))
// Type guard
function isRole(value: string): value is Role {
return (ROLES as readonly string[]).includes(value)
}
typeof ROLES[number] extrait l'union de tous les éléments du tuple. L'article sur les tuples couvre cette syntaxe.
Quand un enum se justifie
Je ne dis pas "jamais d'enum". Quelques cas ou ca se justifie :
- Tu travailles dans un projet Angular. Angular utilise massivement les enums, et le style est etabli.
- Tu as besoin d'un reverse mapping numérique pour de la sérialisation binaire (protocoles réseau, fichiers binaires).
- L'équipe entière est habituee aux enums et le changement de convention n'en vaut pas la peine.
Dans tous les autres cas, sur mes projets et ceux de paltemps.fr, j'utilise des unions litterales ou as const.
Migration enum → union
Si tu as un projet avec des enums et que tu veux migrer :
typescript// Avant
enum Status {
Active = "active",
Inactive = "inactive"
}
function process(s: Status) { /* ... */ }
process(Status.Active)
// Apres
const Status = {
Active: "active",
Inactive: "inactive"
} as const
type Status = ValueOf<typeof Status>
function process(s: Status) { /* ... */ }
process(Status.Active) // ✅ fonctionne toujours
process("active") // ✅ fonctionne aussi maintenant
Le code appelant ne change presque pas. Status.Active fonctionne dans les deux cas. La différence est que la version as const accepte aussi les strings brutes.
Résumé
- Les enums generent du code runtime (objets, IIFE) alors que les unions litterales disparaissent a la compilation
- Les enums string creent un type nominal qui refuse les strings brutes — problématique avec les donnees JSON
- Les enums numériques acceptent n'importe quel number — trou dans le système de types
const enumest déconseillé par l'équipe TypeScript car incompatible avec les build tools modernes- Le pattern
as const+typeofdonne un objet itérable au runtime et une union type-safe a la compilation
Article précédent : 11 - Union vs intersection
Article suivant : 13 - Tuples