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 si someNumber vaut 99. À partir de TypeScript 5.0, assigner un littéral hors limites comme const p: Priority = 99; génère une erreur. Une variable number passe 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 const préserve le type littéral de chaque élément. Le tableau a le type readonly ['CARD', 'BANK_TRANSFER', 'COD'] et non string[].
  • (typeof PAYMENT_METHODS)[number] récupère l’union de tous les éléments, soit 'CARD' | 'BANK_TRANSFER' | 'COD'.
  • isPaymentMethod est un type guard. Après la vérification, TypeScript restreint value à PaymentMethod.
  • Le transtypage vers readonly string[] est nécessaire car includes sur 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 OrderStatus est 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 OrderStatus est '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 enum dans le fichier .d.ts peut casser le code des utilisateurs s’ils n’utilisent pas tsc.

Note : Évitez const enum dans 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 (Pending vs 'PENDING').
  • enum : quand le codebase utilise déjà enum partout et construit complètement avec tsc, ou quand vous voulez vraiment la nature nominale, c’est-à-dire interdire les chaînes brutes.

Erreurs courantes :

  • Utiliser Object.keys sur 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 OrderStatus dè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 à string et 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.

typescriptenumunion typetype safetymeilleures pratiques