📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~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.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

4 champs optionnels = 16 états possibles, 4 valides : passe aux unions discriminées

26 juillet 2026

Ton type d'état a quatre `?`, donc seize combinaisons, dont la plupart n'ont aucun sens métier. Résultat : des `!` partout, un dernier `return` poubelle, et un ordre de `if` qui tient lieu de spécification. Comment passer d'un sac d'optionnels à une union discriminée, ce que le narrowing te rend, les 4 pièges qui cassent le discriminant, et un piège de narrowing que les templates Angular rendent quasi inévitable (vérifié au compilateur).

TypeScript Angular Signals Bonnes pratiques Architecture Templates

`outputFromObservable` et `outputToObservable` : les 2 ponts que ta migration `EventEmitter` → `output()` ignore

20 juillet 2026

Tu passes tes `@Output() EventEmitter` en `output()` et d'un coup ton `.pipe(debounceTime())` ne compile plus, ton service RxJS ne se branche plus, et tes tests hurlent. Les deux fonctions d'interop existent depuis Angular 17.3 (stables depuis la 19) mais 90% des migrations les ratent. Tour des 5 pièges qui cassent ton flux RxJS quand tu passes aux outputs signals, avec les patterns pour reprendre la main.

Angular Signals RxJS Composants Migration Bonnes pratiques

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.