TypeScript le système de types - 12 - Enums vs unions litterales : et pourquoi je déconseillé les enums

Pourquoi les unions litterales sont superieures aux enums dans la plupart des cas. Les problèmes des enums au runtime, les alternatives avec as const.

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 + typeof pour 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 --declaration dans certains cas
  • Les valeurs sont inlinees au moment du build — si une lib exporte un const enum et 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 :

  1. Tu travailles dans un projet Angular. Angular utilise massivement les enums, et le style est etabli.
  2. Tu as besoin d'un reverse mapping numérique pour de la sérialisation binaire (protocoles réseau, fichiers binaires).
  3. 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 enum est déconseillé par l'équipe TypeScript car incompatible avec les build tools modernes
  • Le pattern as const + typeof donne 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

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.