¿Enum o union string literal en TypeScript?

1. Descripción general

Cuando necesitas representar un conjunto de valores fijos como el estado de un pedido o el método de pago, TypeScript te ofrece dos opciones populares: enum y union string literal ('PENDING' | 'PAID'). Ambas formas se ven similares en el editor pero difieren en runtime, en cómo el type checker las procesa y en su compatibilidad con las herramientas modernas.

Este artículo analiza cada enfoque con ejemplos concretos, señala los errores comunes y presenta el patrón as const que combina las ventajas de ambos. Después de leerlo, tendrás criterios claros para elegir el mejor para tu proyecto.

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));

Ejecutando con npx tsx order.ts, obtendrás:

true
[ 'PENDING', 'PAID', 'SHIPPED', 'CANCELLED' ]

Lo importante: enum no es solo un type. El compilador genera un object real en tiempo de ejecución. Por eso puedes llamar a Object.values(OrderStatus). El código JavaScript generado se ve así:

var OrderStatus;
(function (OrderStatus) {
  OrderStatus["Pending"] = "PENDING";
  OrderStatus["Paid"] = "PAID";
  OrderStatus["Shipped"] = "SHIPPED";
  OrderStatus["Cancelled"] = "CANCELLED";
})(OrderStatus || (OrderStatus = {}));

2.2. Numeric enum y reverse mapping

Si no asignas valores, el enum se numerará a 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' ]

El numeric enum tiene reverse mapping: Priority[2] devuelve 'High'. Como resultado, Object.keys devuelve tanto números como nombres. Este es un error clásico cuando renderizas un dropdown desde un enum y ves la lista duplicada.

Nota: El numeric enum sigue aceptando cualquier variable de tipo number. Por ejemplo, const p: Priority = someNumber; compila normalmente, incluso si someNumber es 99. A partir de TypeScript 5.0, asignar un literal fuera de rango como const p: Priority = 99; sí reporta error. Una variable number aún se desliza.

2.3. Enum tiene carácter “nominal”

El string enum no acepta la cadena literal correspondiente:

canCancel(OrderStatus.Paid); // OK
canCancel('PAID');           // Error: Argument of type '"PAID"' is not assignable to parameter of type 'OrderStatus'.

Puedes considerar esto una ventaja: obliga a que todo pase por OrderStatus. Pero cuando los datos provienen del JSON de una API, tienes que hacer un type cast (as OrderStatus) o escribir una función de conversión.

3. Union string literal

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 };
// Error: Type '"PAYPAL"' is not assignable to type 'PaymentMethod'.
COD

El union type se elimina completamente durante la compilación. El resultado JavaScript es solo el object payment con la cadena 'COD', sin código extra. El editor sigue sugiriendo los tres valores cuando escribes method: '.

La debilidad: como no existe en tiempo de ejecución, no puedes iterar sobre los valores de PaymentMethod. Tampoco puedes validar la entrada sin declarar por separado otra lista.

3.1. Obtener la lista de valores con as const

La solución es declarar el array primero y luego deducir el type del array:

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

Explicación:

  • as const preserva el tipo literal de cada elemento. El array tiene tipo readonly ['CARD', 'BANK_TRANSFER', 'COD'] no string[].
  • (typeof PAYMENT_METHODS)[number] obtiene la unión de todos los elementos, es decir, 'CARD' | 'BANK_TRANSFER' | 'COD'.
  • isPaymentMethod es un type guard. Después de la verificación, TypeScript estrecha value a PaymentMethod.
  • El type cast a readonly string[] es necesario porque includes en un tuple literal solo acepta esos literales exactos.

A partir de ahora, la lista y el type siempre están sincronizados. Agregar un elemento al array actualiza automáticamente el type.

4. Patrón object as const: la opción intermedia

Muchos equipos prefieren la sintaxis OrderStatus.Paid del enum pero no quieren sus desventajas. Puedes usar un object 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

Aquí:

  • El mismo nombre OrderStatus se usa tanto para el value (object) como para el type. TypeScript lo permite porque value y type están en dos namespaces diferentes.
  • keyof typeof OrderStatus es 'Pending' | 'Paid' | .... El acceso indexado devuelve la unión de valores 'PENDING' | 'PAID' | ....
  • Todavía puedes escribir OrderStatus.Paid, además de pasar 'SHIPPED' directamente desde JSON sin necesidad de type cast.
  • El código generado es solo un object literal normal, sin IIFE, sin reverse mapping.

5. Exhaustive checking con never

Tanto enum como union soportan la verificación “¿se han manejado todos los casos?”. Esta es la razón principal para usar un conjunto de valores fijo en lugar de string.

import { OrderStatus } from './order-status';

export function statusLabel(status: OrderStatus): string {
  switch (status) {
    case 'PENDING':
      return 'En espera de pago';
    case 'PAID':
      return 'Pagado';
    case 'SHIPPED':
      return 'Enviado';
    case 'CANCELLED':
      return 'Cancelado';
    default: {
      const unreachable: never = status;
      throw new Error(`Estado inválido: ${unreachable}`);
    }
  }
}

Si más tarde agregas Refunded: 'REFUNDED' a OrderStatus pero olvidas agregar un case, la línea const unreachable: never = status; reportará un error:

Type 'string' is not assignable to type 'never'.

Más precisamente, el mensaje dirá '"REFUNDED"' is not assignable to type 'never'. El error aparece en tiempo de compilación, antes de enviar el código a producción. La rama throw sigue siendo útil en runtime cuando datos inesperados llegan desde afuera.

6. Escribir tests para el comportamiento principal

Instala Vitest:

npm install -D vitest

Test para el type guard y la función 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('acepta todos los valores en la lista', () => {
    for (const method of PAYMENT_METHODS) {
      expect(isPaymentMethod(method)).toBe(true);
    }
  });

  it('rechaza valores desconocidos', () => {
    expect(isPaymentMethod('PAYPAL')).toBe(false);
    expect(isPaymentMethod('cod')).toBe(false);
  });
});

describe('statusLabel', () => {
  it('devuelve una etiqueta para cada estado', () => {
    const labels = Object.values(OrderStatus).map(statusLabel);
    expect(labels).toEqual(['En espera de pago', 'Pagado', 'Enviado', 'Cancelado']);
  });

  it('lanza un error cuando los datos en runtime no son válidos', () => {
    expect(() => statusLabel('REFUNDED' as OrderStatus)).toThrow('Estado inválido: REFUNDED');
  });
});

Ejecuta npx vitest run:

 ✓ PaymentMethod > acepta todos los valores en la lista
 ✓ PaymentMethod > rechaza valores desconocidos
 ✓ statusLabel > devuelve una etiqueta para cada estado
 ✓ statusLabel > lanza un error cuando los datos en runtime no son válidos

 Test Files  1 passed (1)
      Tests  4 passed (4)

El segundo test demuestra que el type guard distingue mayúsculas de minúsculas. Este es un detalle fácil de pasar por alto al parsear entrada de formulario.

7. Tooling moderno y const enum

Cada vez más herramientas solo eliminan tipos en lugar de compilar TypeScript completamente. Por ejemplo: el modo type stripping de Node.js, o esbuild y SWC cuando procesan archivos individuales. Estas herramientas no pueden manejar sintaxis que genera código en runtime como enum.

TypeScript 5.8 tiene un flag erasableSyntaxOnly para prohibir este tipo de sintaxis:

{
  "compilerOptions": {
    "erasableSyntaxOnly": true,
    "isolatedModules": true
  }
}

Cuando activas el flag, todas las declaraciones enum reportarán error. Union literal y object as const siguen siendo válidos porque son JavaScript puro más type annotations.

const enum alguna vez se promocionó como un enum “sin costo”, porque el compilador inlinea los valores en lugar de generar un object. Sin embargo, causa muchos problemas:

  • No es seguro usarlo con isolatedModules, porque las herramientas de compilación por archivo no conocen los valores del enum en otros archivos.
  • Al publicar una librería, const enum en archivos .d.ts puede romper el código de los usuarios si no usan tsc.

Nota: Evita const enum en código nuevo, especialmente en librerías. Si quieres evitar el costo en runtime, los union literal ya lo hacen sin riesgos.

8. ¿Cuándo usar cada uno?

Criterio enum Union literal Object as const
Tiene valor en runtime Sí No (necesita array aparte) Sí
Acepta cadena cruda desde JSON Debe hacer type cast Sí Sí
Compatible con type stripping No Sí Sí
Sintaxis X.Value Sí No Sí
Exhaustive check Sí Sí Sí

Recomendaciones prácticas:

  • Union literal: por defecto para la mayoría de casos, especialmente para tipos de props, DTO y parámetros de función. Más limpio, sin código runtime.
  • Array as const + union: cuando necesitas iterar sobre valores (dropdown, validar entrada) o usarlos con librerías de validación.
  • Object as const: cuando el equipo quiere sintaxis similar a enum, o cuando los nombres en código son diferentes del valor real (Pending vs 'PENDING').
  • enum: cuando tu codebase ya lo usa consistentemente y compilas con tsc completo, o cuando realmente quieres semántica nominal, es decir, prohibir pasar cadenas raw.

Errores comunes:

  • Usar Object.keys en numeric enum y recibir una lista duplicada debido al reverse mapping.
  • Permitir que numeric enum acepte cualquier number, luego los datos en la base de datos se desalinean cuando alguien inserta un miembro en el medio del enum. El orden cambia y los valores cambian.
  • Hacer type cast as OrderStatus inmediatamente al recibir datos de la API en lugar de verificar con un type guard. El type checker te creerá, pero los datos incorrectos seguirán adelante.
  • Olvidar as const, causando que el tipo se amplíe a string y pierda toda su utilidad.

9. Conclusión

Enum y union string literal resuelven ambos el problema de los “conjuntos de valores fijos”. La diferencia es que enum genera código en runtime y tiene semántica nominal. Union literal existe solo en la capa de tipos, por lo que es más ligero y compatible con herramientas modernas.

Con código TypeScript nuevo, debes comenzar con union literal. Cuando necesites una lista en runtime, combina con un array u object as const. Sea cual sea tu elección, usa exhaustive check con never y type guard en los límites del sistema. Eso es lo que realmente hace tu código seguro.

typescriptenumunion typetype safetybest practices