Enum ou union de littéraux string en TypeScript ?
1. Aperçu général
Quand vous avez besoin de représenter un ensemble fixe de valeurs comme l’état d’une commande ou une méthode de paiement, TypeScript vous offre deux options populaires : enum et union de littéraux string ('PENDING' | 'PAID'). Les deux approches se ressemblent dans l’éditeur mais diffèrent au runtime, dans la façon dont le type checker les traite et dans leur compatibilité avec les outils modernes.
Cet article analyse chaque approche avec des exemples concrets, signale les erreurs courantes et présente le pattern as const qui combine les avantages des deux. Après votre lecture, vous aurez des critères clairs pour choisir selon votre projet.
2. Enum en TypeScript
2.1. String enum
enum OrderStatus {
Pending = 'PENDING',
Paid = 'PAID',
Shipped = 'SHIPPED',
Cancelled = 'CANCELLED',
}
function canCancel(status: OrderStatus): boolean {
return status === OrderStatus.Pending || status === OrderStatus.Paid;
}
console.log(canCancel(OrderStatus.Paid));
console.log(Object.values(OrderStatus));
En exécutant npx tsx order.ts, vous obtenez :
true
[ 'PENDING', 'PAID', 'SHIPPED', 'CANCELLED' ]
Point important : enum n’est pas seulement un type. Le compilateur génère un vrai objet au runtime. C’est pourquoi vous pouvez appeler Object.values(OrderStatus). Le code JavaScript généré ressemble à ceci :
var OrderStatus;
(function (OrderStatus) {
OrderStatus["Pending"] = "PENDING";
OrderStatus["Paid"] = "PAID";
OrderStatus["Shipped"] = "SHIPPED";
OrderStatus["Cancelled"] = "CANCELLED";
})(OrderStatus || (OrderStatus = {}));
2.2. Enum numérique et reverse mapping
Si vous n’assignez pas de valeurs, l’enum comptera à partir de 0 :
enum Priority {
Low,
Medium,
High,
}
console.log(Priority.High);
console.log(Priority[2]);
console.log(Object.keys(Priority));
2
High
[ '0', '1', '2', 'Low', 'Medium', 'High' ]
L’enum numérique a un reverse mapping : Priority[2] retourne 'High'. Par conséquent, Object.keys retourne à la fois les nombres et les noms. C’est l’erreur classique quand vous rendez un dropdown depuis un enum et voyez la liste doublée.
Note : L’enum numérique accepte toujours n’importe quelle variable de type
number. Par exemple,const p: Priority = someNumber;compile normalement, même sisomeNumbervaut99. À partir de TypeScript 5.0, assigner un littéral hors limites commeconst p: Priority = 99;génère une erreur. Une variablenumberpasse toujours.
2.3. Les enum ont une nature “nominale”
L’enum string n’accepte pas les littéraux string correspondants :
canCancel(OrderStatus.Paid); // OK
canCancel('PAID'); // Erreur: Argument of type '"PAID"' is not assignable to parameter of type 'OrderStatus'.
Vous pouvez voir cela comme un avantage : c’est obligatoire de passer par OrderStatus partout. Mais quand les données viennent du JSON d’une API, vous devez faire un transtypage (as OrderStatus) ou écrire une fonction de conversion.
3. Union de littéraux string
type PaymentMethod = 'CARD' | 'BANK_TRANSFER' | 'COD';
interface Payment {
orderId: string;
method: PaymentMethod;
amount: number;
}
const payment: Payment = { orderId: 'ORD-001', method: 'COD', amount: 250_000 };
console.log(payment.method);
// const wrong: Payment = { orderId: 'ORD-002', method: 'PAYPAL', amount: 1 };
// Erreur: Type '"PAYPAL"' is not assignable to type 'PaymentMethod'.
COD
Le type union est complètement supprimé lors de la compilation. Le code JavaScript résultant ne contient que l’objet payment avec la chaîne 'COD', pas de code superflu. L’éditeur suggère toujours les trois valeurs quand vous tapez method: '.
Point faible : comme il n’existe pas au runtime, vous ne pouvez pas itérer sur les valeurs de PaymentMethod. Vous ne pouvez pas non plus valider l’input sans déclarer une liste séparée.
3.1. Obtenir la liste des valeurs avec as const
La solution est de déclarer le tableau d’abord, puis d’en déduire le type :
export const PAYMENT_METHODS = ['CARD', 'BANK_TRANSFER', 'COD'] as const;
export type PaymentMethod = (typeof PAYMENT_METHODS)[number];
export function isPaymentMethod(value: string): value is PaymentMethod {
return (PAYMENT_METHODS as readonly string[]).includes(value);
}
console.log(PAYMENT_METHODS);
console.log(isPaymentMethod('COD'), isPaymentMethod('PAYPAL'));
[ 'CARD', 'BANK_TRANSFER', 'COD' ]
true false
Explication :
as constpréserve le type littéral de chaque élément. Le tableau a le typereadonly ['CARD', 'BANK_TRANSFER', 'COD']et nonstring[].(typeof PAYMENT_METHODS)[number]récupère l’union de tous les éléments, soit'CARD' | 'BANK_TRANSFER' | 'COD'.isPaymentMethodest un type guard. Après la vérification, TypeScript restreintvalueàPaymentMethod.- Le transtypage vers
readonly string[]est nécessaire carincludessur un tuple littéral n’accepte que ces littéraux.
Désormais, la liste et le type restent toujours synchronisés. Ajouter un élément au tableau, et le type se met à jour automatiquement.
4. Pattern objet as const : un choix au milieu
Beaucoup d’équipes aiment la syntaxe OrderStatus.Paid de l’enum mais ne veulent pas ses inconvénients. Vous pouvez utiliser un objet as const :
export const OrderStatus = {
Pending: 'PENDING',
Paid: 'PAID',
Shipped: 'SHIPPED',
Cancelled: 'CANCELLED',
} as const;
export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];
function canCancel(status: OrderStatus): boolean {
return status === OrderStatus.Pending || status === OrderStatus.Paid;
}
console.log(canCancel(OrderStatus.Paid));
console.log(canCancel('SHIPPED'));
true
false
Ici :
- Le même nom
OrderStatusest utilisé pour la valeur (objet) et le type. TypeScript le permet car la valeur et le type se trouvent dans deux namespaces différents. keyof typeof OrderStatusest'Pending' | 'Paid' | .... L’accès indexé retourne l’union des valeurs'PENDING' | 'PAID' | ....- Vous pouvez toujours écrire
OrderStatus.Paid, tout en passant directement'SHIPPED'depuis du JSON sans transtypage. - Le code généré n’est qu’un objet littéral normal, pas d’IIFE, pas de reverse mapping.
5. Vérification exhaustive avec never
Les enum comme les unions supportent la vérification « avez-vous couvert tous les cas ». C’est la raison principale d’utiliser un ensemble de valeurs fixes plutôt que string.
import { OrderStatus } from './order-status';
export function statusLabel(status: OrderStatus): string {
switch (status) {
case 'PENDING':
return 'En attente de paiement';
case 'PAID':
return 'Payé';
case 'SHIPPED':
return 'En livraison';
case 'CANCELLED':
return 'Annulé';
default: {
const unreachable: never = status;
throw new Error(`État invalide : ${unreachable}`);
}
}
}
Si vous ajoutez ensuite Refunded: 'REFUNDED' à OrderStatus et oubliez d’ajouter un case, la ligne const unreachable: never = status; génère une erreur :
Type 'string' is not assignable to type 'never'.
Plus précisément, le message dit '"REFUNDED"' is not assignable to type 'never'. L’erreur apparaît à la compilation, avant de mettre en production. La branche throw reste utile au runtime, quand des données inattendues arrivent de l’extérieur.
6. Écrire des tests pour le comportement principal
Installer Vitest :
npm install -D vitest
Test pour le type guard et la fonction statusLabel :
import { describe, expect, it } from 'vitest';
import { isPaymentMethod, PAYMENT_METHODS } from './payment-method';
import { statusLabel } from './status-label';
import { OrderStatus } from './order-status';
describe('PaymentMethod', () => {
it('accepte chaque valeur de la liste', () => {
for (const method of PAYMENT_METHODS) {
expect(isPaymentMethod(method)).toBe(true);
}
});
it('rejette les valeurs inattendues', () => {
expect(isPaymentMethod('PAYPAL')).toBe(false);
expect(isPaymentMethod('cod')).toBe(false);
});
});
describe('statusLabel', () => {
it('retourne un libellé pour chaque état', () => {
const labels = Object.values(OrderStatus).map(statusLabel);
expect(labels).toEqual(['En attente de paiement', 'Payé', 'En livraison', 'Annulé']);
});
it('lève une erreur quand les données runtime sont invalides', () => {
expect(() => statusLabel('REFUNDED' as OrderStatus)).toThrow('État invalide : REFUNDED');
});
});
En exécutant npx vitest run :
✓ PaymentMethod > accepte chaque valeur de la liste
✓ PaymentMethod > rejette les valeurs inattendues
✓ statusLabel > retourne un libellé pour chaque état
✓ statusLabel > lève une erreur quand les données runtime sont invalides
Test Files 1 passed (1)
Tests 4 passed (4)
Le deuxième test démontre que le type guard distingue les majuscules des minuscules. C’est un détail facile à négliger lors du parsing d’input depuis un formulaire.
7. Outils modernes et const enum
De plus en plus d’outils supprimez les types au lieu de compiler TypeScript complètement. Par exemple, le mode type stripping de Node.js, ou esbuild et SWC lors du traitement de fichiers individuels. Ces outils ne peuvent pas gérer les syntaxes qui génèrent du code runtime comme enum.
TypeScript 5.8 a un flag erasableSyntaxOnly pour interdire ce type de syntaxe :
{
"compilerOptions": {
"erasableSyntaxOnly": true,
"isolatedModules": true
}
}
Quand vous activez ce flag, chaque déclaration enum génère une erreur. Les unions littérales et les objets as const restent valides car ce sont du JavaScript pur avec des annotations de type.
const enum a été promu comme un enum « sans coût », car le compilateur inline les valeurs au lieu de générer un objet. Cependant, cela cause plusieurs problèmes :
- Ne peut pas être utilisé en toute sécurité avec
isolatedModules, car le compilateur traité par fichier ne connaît pas les valeurs de l’enum dans d’autres fichiers. - Lors de la publication d’une bibliothèque,
const enumdans le fichier.d.tspeut casser le code des utilisateurs s’ils n’utilisent pastsc.
Note : Évitez
const enumdans le code nouveau, en particulier dans les bibliothèques. Si vous voulez zéro coût au runtime, les unions littérales le font déjà sans risque.
8. Quand utiliser quoi ?
| Critère | enum |
Union littérale | Objet as const |
|---|---|---|---|
| Valeur au runtime | Oui | Non (tableau séparé) | Oui |
| Accepte les chaînes brutes du JSON | Doit transtyper | Oui | Oui |
| Compatible avec type stripping | Non | Oui | Oui |
Syntaxe X.Value |
Oui | Non | Oui |
| Vérification exhaustive | Oui | Oui | Oui |
Recommandations pratiques :
- Union littérale : le défaut pour la plupart des cas, surtout pour le type des props, DTO et paramètres de fonction. Le plus compact, pas de code runtime.
- Tableau
as const+ union : quand vous avez besoin d’itérer sur les valeurs (dropdown, validation d’input) ou d’utiliser avec une bibliothèque de validation. - Objet
as const: quand l’équipe veut la syntaxe d’enum, ou quand le nom dans le code diffère de la valeur réelle (Pendingvs'PENDING'). enum: quand le codebase utilise déjà enum partout et construit complètement avectsc, ou quand vous voulez vraiment la nature nominale, c’est-à-dire interdire les chaînes brutes.
Erreurs courantes :
- Utiliser
Object.keyssur un enum numérique et obtenir une liste doublée à cause du reverse mapping. - Laisser un enum numérique accepter n’importe quel
number, puis les données en base de données se décalent quand quelqu’un ajoute un member au milieu de l’enum. L’ordre change, la valeur aussi. - Transtyper en
as OrderStatusdès la réception de données d’API au lieu de vérifier avec un type guard. Le type checker vous fait confiance, mais les données invalides continuent. - Oublier
as const, ce qui fait que le type s’élargit àstringet perd tout son effet.
9. Conclusion
Les enum et les unions de littéraux string résolvent tous deux le problème de « ensemble de valeurs fixes ». La différence est que l’enum génère du code runtime et a une nature nominale. L’union littérale n’existe qu’à la couche type, elle est donc plus légère et plus conviviale avec les outils modernes.
Pour le code TypeScript nouveau, commencez par les unions littérales. Quand vous avez besoin d’une liste au runtime, combinez avec un tableau ou un objet as const. Quelle que soit votre choix, utilisez la vérification exhaustive avec never et les type guards aux limites du système. C’est ce qui rend le code vraiment sûr.