¿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 sisomeNumberes99. A partir de TypeScript 5.0, asignar un literal fuera de rango comoconst p: Priority = 99;sí reporta error. Una variablenumberaú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 constpreserva el tipo literal de cada elemento. El array tiene tiporeadonly ['CARD', 'BANK_TRANSFER', 'COD']nostring[].(typeof PAYMENT_METHODS)[number]obtiene la unión de todos los elementos, es decir,'CARD' | 'BANK_TRANSFER' | 'COD'.isPaymentMethodes un type guard. Después de la verificación, TypeScript estrechavalueaPaymentMethod.- El type cast a
readonly string[]es necesario porqueincludesen 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
OrderStatusse 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 OrderStatuses'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 enumen archivos.d.tspuede romper el código de los usuarios si no usantsc.
Nota: Evita
const enumen 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 (Pendingvs'PENDING'). enum: cuando tu codebase ya lo usa consistentemente y compilas contsccompleto, o cuando realmente quieres semántica nominal, es decir, prohibir pasar cadenas raw.
Errores comunes:
- Usar
Object.keysen 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 OrderStatusinmediatamente 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 astringy 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.