Enum ou union string literal no TypeScript?
1. Visão geral
Quando você precisa representar um conjunto de valores fixos, como o status de um pedido ou um método de pagamento, TypeScript oferece duas opções comuns: enum e union string literal ('PENDING' | 'PAID'). As duas parecem iguais no editor, mas diferem em runtime, em como o type checker as processa e no nível de compatibilidade com ferramentas modernas.
Este artigo analisa cada uma com exemplos práticos, aponta os erros mais comuns e apresenta o padrão as const que combina as vantagens de ambas. Depois de ler, você terá critérios claros para escolher a melhor opção para seu projeto.
2. Enum no 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));
Executando com npx tsx order.ts, você recebe:
true
[ 'PENDING', 'PAID', 'SHIPPED', 'CANCELLED' ]
Ponto importante: enum não é apenas um type. O compilador gera um object real em runtime. Por isso você consegue chamar Object.values(OrderStatus). O código JavaScript gerado fica assim:
var OrderStatus;
(function (OrderStatus) {
OrderStatus["Pending"] = "PENDING";
OrderStatus["Paid"] = "PAID";
OrderStatus["Shipped"] = "SHIPPED";
OrderStatus["Cancelled"] = "CANCELLED";
})(OrderStatus || (OrderStatus = {}));
2.2. Enum numérico e reverse mapping
Se você não atribuir valores, o enum será numerado 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' ]
Enum numérico tem reverse mapping: Priority[2] retorna 'High'. Como consequência, Object.keys retorna tanto números quanto nomes. Este é um erro clássico quando você renderiza um dropdown a partir do enum e a lista aparece duplicada.
Observação: Enum numérico ainda aceita qualquer variável do tipo
number. Por exemplo,const p: Priority = someNumber;compila normalmente, mesmo sesomeNumberfor99. A partir do TypeScript 5.0, atribuir um literal fora do intervalo comoconst p: Priority = 99;reporta um erro. Mas uma variávelnumberainda passa.
2.3. Enum tem natureza “nominal”
String enum não aceita a string literal correspondente:
canCancel(OrderStatus.Paid); // OK
canCancel('PAID'); // Erro: Argument of type '"PAID"' is not assignable to parameter of type 'OrderStatus'.
Você pode ver isso como uma vantagem: força você a sempre passar por OrderStatus. Mas quando os dados vêm do JSON de uma API, você precisa fazer um type cast (as OrderStatus) ou escrever uma função de conversão.
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 };
// Erro: Type '"PAYPAL"' is not assignable to type 'PaymentMethod'.
COD
O type union é completamente removido durante a compilação. O resultado em JavaScript é apenas o object payment com a string 'COD', sem nenhum código extra. O editor ainda oferece as três sugestões quando você digita method: '.
Ponto fraco: como não existe em runtime, você não consegue iterar os valores de PaymentMethod. Você também não consegue validar entrada sem declarar uma lista separada.
3.1. Obtendo a lista de valores com as const
A solução é declarar um array primeiro e depois inferir o type dele:
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
Explicação:
as constpreserva o tipo literal de cada elemento. O array tem tiporeadonly ['CARD', 'BANK_TRANSFER', 'COD']e nãostring[].(typeof PAYMENT_METHODS)[number]obtém o union de todos os elementos, ou seja,'CARD' | 'BANK_TRANSFER' | 'COD'.isPaymentMethodé um type guard. Depois de checá-lo, TypeScript restringevalueparaPaymentMethod.- O type cast para
readonly string[]é necessário porqueincludesem uma tupla literal só aceita esses literals específicos.
Agora, a lista e o type sempre permanecem sincronizados. Adicionar um elemento ao array faz o type se atualizar automaticamente.
4. Padrão as const object: o meio termo
Muitos times gostam da sintaxe OrderStatus.Paid do enum, mas não querem suas desvantagens. Você pode usar um 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
Aqui:
- O mesmo nome
OrderStatusé usado tanto para o value (object) quanto para o type. TypeScript permite isso porque value e type estão em namespaces diferentes. keyof typeof OrderStatusé'Pending' | 'Paid' | .... O indexed access retorna um union dos valores'PENDING' | 'PAID' | ....- Você ainda pode escrever
OrderStatus.Paide passar direto'SHIPPED'do JSON sem fazer type cast. - O código gerado é apenas um object literal normal, sem IIFE, sem reverse mapping.
5. Verificação exaustiva com never
Tanto enum quanto union suportam verificar se “todos os casos foram tratados”. Este é o principal motivo para usar um conjunto de valores fixos em vez de string.
import { OrderStatus } from './order-status';
export function statusLabel(status: OrderStatus): string {
switch (status) {
case 'PENDING':
return 'Aguardando pagamento';
case 'PAID':
return 'Pagamento recebido';
case 'SHIPPED':
return 'Em transporte';
case 'CANCELLED':
return 'Cancelado';
default: {
const unreachable: never = status;
throw new Error(`Status inválido: ${unreachable}`);
}
}
}
Se mais tarde você adicionar Refunded: 'REFUNDED' a OrderStatus e esquecer de adicionar um case, a linha const unreachable: never = status; reportará um erro:
Type 'string' is not assignable to type 'never'.
Mais precisamente, a mensagem dirá '"REFUNDED"' is not assignable to type 'never'. O erro aparece no momento da compilação, antes do código chegar à produção. O branch throw ainda é útil em runtime, quando dados inválidos chegam de fora.
6. Escrevendo testes para o comportamento principal
Instale Vitest:
npm install -D vitest
Testes para o type guard e a função 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('aceita todos os valores da lista', () => {
for (const method of PAYMENT_METHODS) {
expect(isPaymentMethod(method)).toBe(true);
}
});
it('rejeita valores inválidos', () => {
expect(isPaymentMethod('PAYPAL')).toBe(false);
expect(isPaymentMethod('cod')).toBe(false);
});
});
describe('statusLabel', () => {
it('retorna um rótulo para cada status', () => {
const labels = Object.values(OrderStatus).map(statusLabel);
expect(labels).toEqual(['Aguardando pagamento', 'Pagamento recebido', 'Em transporte', 'Cancelado']);
});
it('lança erro quando dados em runtime são inválidos', () => {
expect(() => statusLabel('REFUNDED' as OrderStatus)).toThrow('Status inválido: REFUNDED');
});
});
Execute npx vitest run:
✓ PaymentMethod > aceita todos os valores da lista
✓ PaymentMethod > rejeita valores inválidos
✓ statusLabel > retorna um rótulo para cada status
✓ statusLabel > lança erro quando dados em runtime são inválidos
Test Files 1 passed (1)
Tests 4 passed (4)
O segundo teste mostra que o type guard diferencia maiúsculas de minúsculas. É um detalhe fácil de perder ao fazer parse de input do formulário.
7. Ferramentas modernas e const enum
Cada vez mais ferramentas apenas removem types em vez de compilar TypeScript completamente. Por exemplo: o modo type stripping do Node.js, ou esbuild e SWC ao processar cada arquivo separadamente. Essas ferramentas não conseguem lidar com sintaxe que gera código runtime como enum.
TypeScript 5.8 tem a flag erasableSyntaxOnly para proibir esse tipo de sintaxe:
{
"compilerOptions": {
"erasableSyntaxOnly": true,
"isolatedModules": true
}
}
Quando a flag está ativada, qualquer declaração enum reportará um erro. Union literals e objects as const ainda são válidos porque são JavaScript puro mais type annotations.
const enum foi uma vez promovido como um enum “sem custo”, porque o compilador inline os valores em vez de gerar um object. Porém, ele causa vários problemas:
- Não pode ser usado com segurança com
isolatedModules, porque ferramentas que compilam arquivo por arquivo não conhecem os valores do enum em outros arquivos. - Ao publicar uma biblioteca,
const enumno arquivo.d.tspode quebrar o código do usuário se ele não usartsc.
Observação: Evite
const enumem código novo, especialmente em bibliotecas. Se você quer evitar custo de runtime, union literals já fazem isso sem os riscos.
8. Quando usar qual?
| Critério | enum |
Union literal | Object as const |
|---|---|---|---|
| Tem valor em runtime | Sim | Não (precisa de array separado) | Sim |
| Aceita string bruta do JSON | Deve fazer type cast | Sim | Sim |
| Compatível com type stripping | Não | Sim | Sim |
Sintaxe X.Value |
Sim | Não | Sim |
| Verificação exaustiva | Sim | Sim | Sim |
Recomendações práticas:
- Union literal: padrão para a maioria dos casos, especialmente para tipos de props, DTOs e parâmetros de função. Mais conciso, sem código runtime.
- Array
as const+ union: quando você precisa iterar os valores (dropdown, validar entrada) ou usar com bibliotecas de validação. - Object
as const: quando o time quer sintaxe similar a enum, ou quando o nome no código é diferente do valor real (Pendingvs'PENDING'). enum: quando a codebase já usa de forma consistente e faz build comtsccompleto, ou quando você realmente quer comportamento nominal, ou seja, proibir passar strings brutas.
Erros comuns:
- Usar
Object.keysem enum numérico e receber uma lista duplicada por causa do reverse mapping. - Deixar enum numérico aceitar qualquer
number, depois os dados no DB ficam errados quando alguém adiciona um membro no meio do enum. A ordem muda, os valores também. - Fazer type cast
as OrderStatusdireto ao receber dados da API em vez de validar com type guard. O type checker acredita em você, mas dados inválidos passam adiante. - Esquecer
as const, causando que o type seja ampliado parastringe perde todo o efeito.
9. Conclusão
Enum e union string literal resolvem o problema de “conjunto de valores fixos”. A diferença é que enum gera código runtime e tem natureza nominal. Union literal existe apenas na camada de type, então é mais leve e amigável com ferramentas modernas.
Para código TypeScript novo, comece com union literal. Quando precisar de uma lista em runtime, combine com um array ou object as const. Qualquer que seja sua escolha, use verificação exaustiva com never e type guards nas fronteiras do seu sistema. Isso é o que realmente deixa seu código seguro.