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 se someNumber for 99. A partir do TypeScript 5.0, atribuir um literal fora do intervalo como const p: Priority = 99; reporta um erro. Mas uma variável number ainda 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 const preserva o tipo literal de cada elemento. O array tem tipo readonly ['CARD', 'BANK_TRANSFER', 'COD'] e não string[].
  • (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 restringe value para PaymentMethod.
  • O type cast para readonly string[] é necessário porque includes em 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.Paid e 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 enum no arquivo .d.ts pode quebrar o código do usuário se ele não usar tsc.

Observação: Evite const enum em 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 (Pending vs 'PENDING').
  • enum: quando a codebase já usa de forma consistente e faz build com tsc completo, ou quando você realmente quer comportamento nominal, ou seja, proibir passar strings brutas.

Erros comuns:

  • Usar Object.keys em 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 OrderStatus direto 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 para string e 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.

typescriptenumunion typetype safetybest practices