~10 min de lecture
enum laisse du JavaScript derrière lui, et Node refuse déjà de l'exécuter
Tu dois modéliser un ensemble fermé de valeurs. Statuts de commande, rôles, directions. Tu appliques le réflexe hérité de Java ou de C# :
enum Status {
Idle,
Loading,
}
Ça compile. Ton IDE t'autocomplète Status.. Tout va bien, jusqu'au jour où tu regardes ce qui sort du compilateur, et jusqu'au jour où un runtime refuse de l'exécuter.
Vérifié sur TypeScript 6.0.3, Angular 22, Node 22 et esbuild 0.28. Les versions sont précisées quand la réponse en dépend.
TL;DR
| Critère | enum |
Union de littéraux |
|---|---|---|
| JS émis | un objet + une IIFE | zéro octet |
| Utilisable directement en template Angular | non (TS2551) |
oui |
| Exécutable par Node en type-stripping | non | oui |
Survit à erasableSyntaxOnly |
non (TS1294) |
oui |
| Refuse une valeur brute équivalente (typage nominal) | oui, uniquement pour les enums de chaînes | non |
Règle : union de littéraux par défaut ; enum seulement quand tu veux interdire la valeur brute, et en connaissant la facture.
Ce que l'enum laisse derrière lui
TypeScript efface. Tes type, tes interface, tes annotations, tes génériques : tout disparaît, il ne reste que du JavaScript. C'est le contrat de base du langage.
L'enum fait partie du petit groupe qui déroge à ce contrat, avec namespace et les paramètres de propriété de constructeur. C'est de loin le plus utilisé des trois. Voilà ce que produit celui de l'introduction :
var Status;
(function (Status) {
Status[Status["Idle"] = 0] = "Idle";
Status[Status["Loading"] = 1] = "Loading";
})(Status || (Status = {}));
Deux valeurs déclarées, une IIFE et un objet vivant dans ton bundle. Et ce n'est pas réservé aux enums numériques. Un enum de chaînes, souvent présenté comme la version "propre", produit exactement le même genre de chose :
export var Str;
(function (Str) {
Str["Idle"] = "idle";
Str["Loading"] = "loading";
})(Str || (Str = {}));
Une union de littéraux, elle, produit zéro octet. type Status = 'idle' | 'loading' n'existe qu'à la compilation, comme tout le reste de TypeScript.
Regarde aussi la double affectation dans la version numérique : Status[Status["Idle"] = 0] = "Idle". C'est le reverse mapping. L'objet contient les deux sens de lecture, donc quatre entrées pour deux valeurs :
Status.Idle; // 0
Status[0]; // 'Idle'
Pratique une fois tous les six mois. On va voir qu'il se paie plus souvent que ça.
Le pire piège : réordonner un enum réécrit tes données
Celui-là ne se voit pas en review, ne casse aucun test, et corrompt des données en production.
La valeur d'un enum numérique dont tu n'écris pas les valeurs, c'est sa position de déclaration. Donc dès que tu la persistes (API, localStorage, base, TransferState), c'est un entier qui part sur le fil :
enum OrderStatus { Draft, Sent }
JSON.stringify({ status: OrderStatus.Sent }); // {"status":1}
Plus tard, quelqu'un ajoute un état, logiquement, au bon endroit :
enum OrderStatus { Draft, Pending, Sent }
La commande persistée à 1 valait Sent. Elle vaut maintenant Pending. Vérifié à l'exécution, pour lever tout doute :
v1 persiste : {"status":1}
relu en v2 : 1 -> Pending
Aucune erreur, aucun warning, aucun test rouge. Juste des commandes expédiées qui redeviennent en attente. Le contrat de données reposait sur l'ordre de déclaration d'un enum, et personne ne le savait.
Sois précis sur le coupable : ce n'est pas enum en soi, c'est la numérotation implicite. Écrire Draft = 10, Sent = 20 et ne jamais réattribuer une valeur te protège. Encore faut-il que toute l'équipe connaisse la règle, pour toujours. Un enum de chaînes évite le cas par construction, et une union de littéraux aussi : il n'y a pas d'index à décaler, la valeur est la donnée.
Le reverse mapping : ton <select> a deux fois trop d'options
Le reverse mapping te revient en pleine figure dès que tu traites l'enum comme un objet, ce que tout le monde finit par faire pour remplir une liste déroulante.
enum Num { Idle, Loading }
Object.values(Num); // ?
La réponse, à l'exécution :
["Idle","Loading",0,1]
Quatre entrées pour deux valeurs. Et les clés sont tout aussi surprenantes :
Object.keys(Num) -> ["0","1","Idle","Loading"]
Alimente un @for avec ça et tu affiches quatre options, dont deux absurdes. Le bug classique consiste alors à filtrer à la main :
const values = Object.values(Num).filter((v) => typeof v === 'number');
Une ligne de nettoyage pour réparer une structure que tu n'as jamais demandée. Un enum de chaînes n'a pas ce problème (Object.values(Str) renvoie bien ["idle","loading"]), ce qui est piégeux d'une autre façon : basculer un enum numérique en enum de chaînes change le comportement du code situé ailleurs.
La taxe Angular : ton enum ne passe pas dans un template
Le template d'un composant Angular ne voit que les membres de la classe. Un enum importé n'en est pas un.
export enum Status { Idle = 'idle', Loading = 'loading' }
@Component({
selector: 'app-order',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@switch (status()) {
@case (Status.Loading) { <p>chargement</p> }
@default { <p>autre</p> }
}
`,
})
export class Order {
protected readonly status = signal<Status>(Status.Idle);
}
Le compilateur de templates refuse, sans ambiguïté :
error TS2551: Property 'Status' does not exist on type 'Order'. Did you mean 'status'?
Le contournement est connu, et c'est justement le problème :
export class Order {
protected readonly Status = Status; // uniquement pour le template
protected readonly status = signal<Status>(Status.Idle);
}
Une propriété de classe qui n'existe que pour réexposer un import, sur chaque composant qui utilise l'enum. Ce n'est pas une ligne dramatique, mais c'est une ligne de plomberie répétée indéfiniment, et un champ dont le nom ne diffère de celui du signal que par une majuscule.
Avec une union de littéraux, la question ne se pose pas :
type Status = 'idle' | 'loading';
@Component({
template: `
@switch (status()) {
@case ('loading') { <p>chargement</p> }
@default { <p>autre</p> }
}
`,
})
export class Order {
protected readonly status = signal<Status>('idle');
}
Le littéral est écrit là où il est lu. Rien à importer, rien à réexposer.
Une précision honnête au passage : dans ce template, status() n'est pas rétréci à l'intérieur du @case, parce que le compilateur de templates ne narrow pas les appels de fonction. Si tu as besoin du narrowing, il te faut une référence stable via @let. C'est un sujet à part entière, traité dans l'article sur les unions discriminées.
Node refuse déjà de l'exécuter
C'est le point qui change la nature du sujet. Il ne s'agit plus d'un arbitrage de style, mais d'une direction prise par l'écosystème.
Node exécute désormais du TypeScript directement, en supprimant les annotations de type plutôt qu'en les compilant. Un mécanisme qui efface ne peut, par construction, rien générer. Donc :
$ node strip.ts
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
Le même fichier écrit avec une union de littéraux s'exécute sans broncher.
Côté compilateur, TypeScript expose la même contrainte depuis la 5.8 avec le flag erasableSyntaxOnly, qui n'autorise que la syntaxe qui s'efface. Sous ce flag, enum et const enum sont tous les deux refusés :
error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
Deux nuances utiles. Un declare enum ambiant passe le flag, puisqu'il ne génère rien. Et les décorateurs experimentalDecorators, bien qu'ils génèrent du JavaScript, ne sont pas attrapés par erasableSyntaxOnly : c'est ce qui permet à un projet Angular de viser ce flag.
Tu ne l'as peut-être pas activé aujourd'hui. Mais chaque enum que tu écris est une ligne à réécrire le jour où tu le feras, ou le jour où un outil de ta chaîne l'imposera.
const enum : ce qu'on lui reproche à tort
L'objection classique, à ce stade : "j'utilise const enum, c'est inliné, ça ne coûte rien". On lit souvent qu'elle ne tient plus dès que le build transpile fichier par fichier. Vérification faite, cette contre-objection est fausse pour une app Angular moderne.
esbuild inline les const enum, y compris d'un fichier à l'autre en mode bundle :
// consumer.ts
console.log(0 /* Up */);
Le module qui déclarait l'enum a purement disparu. Et le builder Angular ne passe même pas systématiquement par esbuild pour ça : il émet via le programme TypeScript complet tant que isolatedModules n'est pas actif, ce qui inline aussi.
Autrement dit, l'argument de la taille ne vaut rien contre const enum. Il fallait le dire.
Ce qui reste vrai, en revanche : const enum tombe exactement comme enum sous erasableSyntaxOnly, et Node refuse tout autant de l'exécuter. Il est fragile en travers des frontières de paquets, où la valeur inlinée chez le consommateur se fige au moment de la compilation. Et il ne règle aucun des trois pièges vus plus haut, puisqu'ils concernent l'enum ordinaire.
Le vrai reproche à const enum n'est donc pas son poids. C'est qu'il te fait payer la complexité d'une construction en voie de fermeture pour économiser des octets que le bundler te rendait de toute façon.
L'argument honnête en sa faveur : il est nominal, l'union ne l'est pas
Il y en a un, et il est réel, mais il est plus étroit qu'on ne le dit.
D'abord, le vocabulaire, parce qu'il porte tout l'argument. TypeScript est structurel : deux types sont compatibles s'ils ont la même forme, peu importe leur nom. C'est pour ça que tu peux passer un objet littéral là où une interface est attendue, sans jamais nommer cette interface. Un système nominal fait l'inverse : l'identité vient du nom, pas de la forme, donc deux types de forme identique mais de noms différents restent incompatibles. C'est le régime par défaut en Java ou en C#.
L'enum de chaînes est l'un des rares endroits où TypeScript se comporte de façon nominale :
enum Str { Idle = 'idle', Loading = 'loading' }
const s: Str = 'idle';
// error TS2322: Type '"idle"' is not assignable to type 'Str'.
La chaîne brute est refusée alors que sa valeur est rigoureusement identique à celle de Str.Idle. Ce n'est pas la forme qui est en cause, c'est l'origine : il faut passer par le nom. Avec une union de littéraux, 'idle' est évidemment accepté, puisque c'est le type lui-même.
L'intérêt pratique : un identifiant qui doit venir du serveur, un token déjà validé, une unité de mesure. Le typage nominal empêche de fabriquer la valeur à la main quelque part au milieu du code.
Trois réserves, quand même.
La nominalité ne vaut que pour les enums de chaînes. Un enum numérique n'en a aucune :
enum Num { Idle, Loading }
const a: Num = 0; // aucune erreur
Un type brandé fait la même chose pour zéro octet. Si tu veux vraiment qu'une valeur ne soit constructible que par un chemin unique, l'enum n'est pas ta seule option :
type UserId = string & { readonly __brand: unique symbol };
const bad: UserId = 'abc';
// error TS2322: Type 'string' is not assignable to type 'UserId'.
Même garantie, aucun JavaScript généré, aucun des pièges de cet article.
Et la garantie reste statique. Un as ou un JSON.parse la contourne, comme toujours en TypeScript.
Tant qu'on y est, un argument qu'on lit encore souvent contre les enums et qui n'est plus vrai depuis TypeScript 5.0 : "on peut assigner n'importe quel nombre à un enum numérique".
enum Num { Idle, Loading }
const bad: Num = 5;
// error TS2322: Type '5' is not assignable to type 'Num'.
La brèche subsiste seulement si un membre est calculé (enum Mixed { A = 1, B = f() }), auquel cas const m: Mixed = 42 passe encore. Autant critiquer l'enum pour ce qu'il coûte réellement.
Le remplacement, dans les trois cas de figure
Cas 1 : tu as juste besoin du type
Union de littéraux, point final.
type Status = 'idle' | 'loading' | 'success' | 'failure';
Zéro JavaScript généré, utilisable partout. Et c'est exactement la brique d'une union discriminée : le jour où chaque état porte ses propres données, tu n'as qu'à donner un corps à chaque membre.
Cas 2 : tu as besoin des valeurs au runtime
Pour itérer, remplir un <select>, valider une entrée. Un objet as const fait le travail, et en lui donnant le nom de l'enum que tu remplaces, les appels existants continuent de compiler à l'identique :
export const Status = {
Idle: 'idle',
Loading: 'loading',
Success: 'success',
Failure: 'failure',
} as const;
export type Status = (typeof Status)[keyof typeof Status];
// 'idle' | 'loading' | 'success' | 'failure'
Object.values(Status); // ['idle','loading','success','failure']
TypeScript autorise un même nom pour une valeur et un type, donc Status.Loading et status: Status cohabitent exactement comme avec l'enum.
as const suffit ici parce qu'on fige un objet source de vérité, dont le type se dérive. Ce n'est pas le cas où l'on préfère satisfies, qui sert à valider une valeur contre un type existant.
Cas 3 : tu associes une donnée à chaque valeur
Un libellé, une icône, une couleur par statut. Record fait mieux qu'un switch, parce qu'il t'oblige à être exhaustif :
const LABELS: Record<Status, string> = {
idle: 'Prêt',
loading: 'Chargement',
success: 'Terminé',
failure: 'Échec',
};
L'intérêt n'est pas dans la concision, il est dans ce qui se passe à la prochaine évolution du modèle. Ajoute 'refreshing' à l'union, et la map ne compile plus tant que tu n'as pas fourni son libellé. Le compilateur t'a donné la liste des endroits à mettre à jour.
En résumé
| Besoin | Choix |
|---|---|
| Un ensemble fermé de valeurs, type uniquement | Union de littéraux |
| Idem + itération ou validation au runtime | Objet as const + type dérivé |
| Chaque valeur porte ses propres données | Union discriminée |
| Interdire la construction depuis une valeur brute | Type brandé (ou enum de chaînes) |
La migration se fait sans big bang : un enum de chaînes se remplace par un objet as const de même nom, mêmes clés, mêmes valeurs. Les appels qui écrivent Status.Loading compilent à l'identique, et ceux qui recevaient déjà des chaînes du serveur arrêtent de passer par un cast.
Une seule chose à retenir : enum est une construction qui laisse du JavaScript derrière elle, dans un langage dont la promesse est de n'en laisser aucun. Ça se paie en données corrompues au réordonnancement, en plomberie dans tes composants Angular, et bientôt en erreurs de compilation.
Écris type Status = 'idle' | 'loading'. Tu n'y perds que l'IIFE.