Uma das conversas mais polemicas em volta do TypeScript é o uso de Enums. Nesse artigo eu quero mostrar os pontos positivos e negativos de um enum, e uma opinião pessoal sobre o que eu uso e por que.

Se você está na comunidade de TS há um tempo já sabe que existem dois lados assim como os adoradores de Java e JavaScript, mas se você está chegando agora, deixa eu te explicar sobre o que eu estou falando.

Enums e TypeScript

Em TS você pode definir enumeradores, esses enumeradores são reflexos de uma proposta adicionada no TC39 há anos, mas na verdade, o TS veio antes disso. Quando o TS foi criado, eles acreditavam que não haveria a ideia de adicionar um enumerador na linguagem, e enumeradores são de fato úteis na maioria das linguagens tipadas.

Por isso que, desde a primeira versão, o TS já tem suporte a enumeradores por padrão. Mas o que é um enumerador?

Eu não vou explicar 100% do que é tudo isso aqui, mas você pode achar muito conteúdo sobre isso na documentação ou então, eu também falo bastante disso no meu treinamento de TypeScript a Formação TS.

Vem aprender comigo!

Quer aprender mais sobre criptografia e boas práticas com #TypeScript?

Se inscreva na Formação TS!

Enums são enumeradores de constantes, quando você quer dar um nome para uma lista de coisas, um enum é a sua saída. Eles podem ser computados automaticamente, como abaixo:

enum Country {
  Germany, // 0
  Sweden, // 1
  USA // 2
}

Aqui cada país vai ter um número associado automaticamente, começando do 0. Ou então enumeradores constantes como esse:

enum Country {
  Germany = 'DE',
  Sweden = 'SE',
  USA = 'US',
}

Enumeradores funcionam como objetos no runtime e também como tipos, então você pode passar algo como:

function setCountry (country: Country) {}

setCountry(Country.Germany)

E você também pode obter as chaves usando Object.keys(Country), por exemplo.

E isso é basicamente o principal de enums que a gente vai precisar saber.

A polêmica dos enums

Existe uma crescente comunidade de pessoas que não gostam de usar enumeradores no código. Pelos mais variados motivos (que a gente vai discutir aqui já já), não é muito difícil achar alguma coisa no Youtube se você colocar "TypeScript Enums" na busca.

Mas o que eu nunca gostei foi que, para mim, nenhum desses motivos era forte o suficiente para poder simplesmente parar de usar enumeradores, mas os motivos a favor também não eram lá essas coisas, e então?

Como eu sempre tive essa dúvida, agora eu vou compartilhar com você quais são os dois lados dessa história para sanar esse problema de uma vez por todas.

Argumentos contra enums

Vamos primeiro aos argumentos contra enumeradores.

Não é algo que existe no JavaScript

Esse é um papo antigo sobre por que não usar enums: "JavaScript não vem com isso de fábrica".

Entendo o porquê disso, considerando que o TypeScript é basicamente o JavaScript com esteroides, ou seja, com tipos. Então, na teoria, tudo que é JavaScript deveria ser TypeScript também.

A galera costuma se apoiar muito nesse argumento:

Se tirar todos os tipos de um código TypeScript, o que sobra tem que ser JavaScript puro.

E isso até que faz sentido, mas se a gente for na dessa de tirar o código TypeScript, os enums também deveriam sair né. Além do mais, esse argumento nem é tão forte assim, por alguns motivos:

  1. Existe uma proposta pra adicionar isso na linguagem (tá meio parada e talvez esquecida, mas tá lá)
  2. E não é como se fosse nossa responsabilidade fazer isso, o TS já tem um compilador que serve justamente para remover as partes que não são JS e fazer o código funcionar

Enums geram código em runtime

Por padrão, o TS não deve gerar código em tempo de execução, mas tem algumas coisas que quebram essa regra como decorators e enums.

Isso significa que o código que você vê no final não está apenas removendo o enum, mas cada enum gera um objeto JS.

Então, um enum assim:

enum X {
    a,
    b,
    c
}

Geraria isso:

var X;
(function (X) {
    X[X["a"] = 0] = "a";
    X[X["b"] = 1] = "b";
    X[X["c"] = 2] = "c";
})(X || (X = {}));

Um dos argumentos do porquê deveríamos nos importar com o que o TSC gera no final é que o Babel e outros compiladores usam plugins e isso pode confundi-los, mas isso na verdade não é realmente um argumento, porque se esses plugins não levam em conta uma característica base de uma linguagem, eles não são bons plugins.

Objetos de enums não se comportam como queremos

Quando você tem o enum do parágrafo acima:

enum X {
    a,
    b,
    c
}

O objeto final terá uma sintaxe de objeto com duplo valor:

var X;
(function (X) {
    X[X["a"] = 0] = "a";
    X[X["b"] = 1] = "b";
    X[X["c"] = 2] = "c";
})(X || (X = {}));

O que significa que a vai ter o valor de 0, mas também X[0] vai ter o valor de a, e isso é verdade se você fizer um console.log(Object.entries(X)):

[["0", "a"], ["1", "b"], ["2", "c"], ["a", 0], ["b", 1], ["c", 2]]

Isso é algo que incomoda, porque não é o que a gente está esperando de um objeto.

Mas, foi feito dessa forma para que possamos acessar X.a e obter o valor de a (que é 0), mas também acessar por índice e obter a chave, então X[0] deveria ser a.

No entanto, isso não acontece se você usar string enums. Então se a gente tiver um enum de Métodos HTTP:

enum HTTPMethods {
	GET = 'GET,
	POST = 'POST'
}

O objeto final seria:

"use strict";
var HTTPMethods;
(function (HTTPMethods) {
    HTTPMethods["GET"] = "GET";
    HTTPMethods["POST"] = "POST";
})(HTTPMethods || (HTTPMethods = {}));

O que apenas atribui a string ao valor e não ao índice. Então no nosso Object.entries não veríamos as chaves [0, 1, 2] porque elas não existem.

Enums não aceitam valores que não são do enum

Quando você faz algo como:

enum LogLevel {
	DEBUG = 'DEBUG', 
	WARNING = 'WARNING',
	ERROR = 'ERROR'
}

function log (msg: string, level: LogLevel) {}

log('hey', 'DEBUG')

Você recebe um erro, porque level não pode ser um membro que não existe no enum, o que aparentemente é esperado pela galera, já que ambos tem o mesmo valor.

Isso tem uma coisa muito interessante, porque o TypeScript usa um sistema de tipos estrutural então ele não deveria se importar com o nome, apenas o valor, mas os enums meio que quebram essa regra, porque os tipos se tornam nominais, então criar um outro enumerador LogLevel2 e passar o valor para a função também daria erro

enum LogLevel2 {
	DEBUG = 'DEBUG', 
	WARNING = 'WARNING',
	ERROR = 'ERROR'
}

function log (msg: string, level: LogLevel) {}

log('hey', LogLevel2.DEBUG) // Erro

Porque LogLevel e LogLevel2 não são a mesma coisa.

Eu entendo esse ponto, principalmente vindo de uma linguagem muito aberta como o JavaScript. Mas também, qual o sentido de enumerar se você pode simplesmente passar qualquer coisa?

Para contornar isso, as pessoas usam POJOs (Plain Old JavaScript Objects) para contornar os enums tipo assim:

const LogLevel = {
	DEBUG: 'DEBUG', 
	WARNING: 'WARNING',
	ERROR: 'ERROR'
} as const
typeof LogLevel[keyof typeof LogLevel]

function log (msg: string, level: LogLevel) {}

log('hey', 'DEBUG')

O que permite tanto usar a string 'DEBUG' ainda mantendo o intellisense e também usar o tipo direto por LogLevel.DEBUG, mas, você precisa escrever o dobro de texto.

Enums computados podem ter instruções if falsas

Se você fizer:

enum A {
  User,
  Admin
}

if (A.User) {
  // isso não vai executar
}

Porque User é 0, logo, evite usar enums computados.

Argumentos a favor de enums

Agora vamos aos argumentos a favor de enums.

Refatoração Mais Rápida

Se precisar substituir a string 'POST' no enum que definimos anteriormente:

enum HTTPMethods {
	GET = 'GET,
	POST = 'POST'
}

Podemos apenas mudar o valor do enum para 'post' e pronto, nada mais precisa ser feito já que o valor será usado por todos os membros que utilizam esse enum.

Se tivéssemos um union type como GET | POST e depois decidíssemos mudar para get | post, todos os lugares agora teriam um erro de tipo.

Eu já pessoalmente ouvi coisas do tipo:

Esse argumento de manutenção de código para enums não é muito forte. Quando adicionamos um novo membro a um enum ou union, ele raramente muda após a criação. Se usarmos uniões, é verdade que podemos ter que gastar algum tempo atualizando em vários lugares, mas não é um grande problema porque acontece raramente. Mesmo quando isso acontece, os erros de tipo podem nos mostrar quais atualizações fazer.

O que não é realmente verdade, porque se você está trabalhando com projetos grandes, como a gente faz aqui na Klarna, isso não é tão "raro" e o que é ainda menos verdade é a parte:

"podemos ter que gastar algum tempo atualizando-os em vários lugares, mas não é um grande problema porque acontece raramente"

Porque a maioria dessas pessoas não está fazendo uma alteração em 500 arquivos de 1500 linhas cada. Quando um projeto é pequeno, até médio, isso é ok. Mas quando você passa para o ramo de projetos gigantes, tudo isso para de fazer sentido e enums podem sim salvar sua refatoração.

Strings Estritas e consistência

Você pode argumentar que ser mais rigoroso sobre os parâmetros que você passa é melhor do que deixá-los abertos. Já que o TypeScript tem o objetivo de ser type safe e trazer segurança para o seu código.

Você pode tornar seu código mais estrito usando string enums, que vão te obrigar a usar esse enum para passar o valor a um objeto, dessa forma, você não pode usar strings simples.

E ai você tem outro argumento a favor, consistência. Quando você usa strings diretas no código, como fizemos com o 'DEBUG', você fica pensando "De onde surgiu esse valor? O que é isso?" que é o que a gente chama de magic strings.

Isso é péssimo pra manutenção, enums ajudam a manter o seu código consistente, estrito e seguro.

Então, qual é o veredito?

A verdade é que não existe um veredito. Eu pessoalmente sou do time que usa enums, porque eles são mais expressivos do que objetos, e são mais semânticos (pelo mesmo motivo que usamos <main> e não <div> em um HTML).

Mas eu reconheço todos esses problemas de enumeradores, então, para ajudar eu vou deixar aqui algumas dicas de como contornar enums se você não quiser usá-los, porém a principal dica é:

SEJA CONSISTENTE

Se você está usando enums, não misture com objetos, se você está usando apenas objetos, não misture com enums. Saiba quando usar um enum ou não ao invés de ficar usando enums para tudo.

Então se você não quiser usar enums de forma nenhuma, em vez de ter um enum, você pode usar um union type ou um objeto JavaScript.

enum LogLevel {
	DEBUG = 'DEBUG', 
	WARNING = 'WARNING',
	ERROR = 'ERROR'
}

Você poderia fazer isso:

const LogLevel = {
	DEBUG: 'DEBUG', 
	WARNING: 'WARNING',
	ERROR: 'ERROR'
} as const

// Chaves
type LogLevel = keyof typeof LogLevel
// DEBUG | WARNING | ERROR

// Valores
type LogLevelValue = typeof LogLevel[keyof typeof LogLevel]
// 'DEBUG' | 'WARNING' | 'ERROR'

Dessa forma, você tem tanto o objeto quanto o tipo para esse valor, as chaves funcionariam como esperado, você pode passar strings para um valor e tudo mais.

A desvantagem é escrever mais e ter que se repetir no objeto LogLevel e no tipo, então é o dobro da escrita.

Conclusões

Há algumas conclusões importantes a partir disso, a primeira é:

Não use enums computados

Evite usar enums numéricos computados, eles são propensos a vários erros (como a gente já viu), e você não controla a ordem das coisas.

Em vez disso, use enums definidos como:

enum Country {
  Germany = 'DE',
  Sweden = 'SE',
  USA = 'US',
}

Eles podem ser números (mas evite), mas sempre definidos. Nunca faça:

enum Country {
  Germany,
  Sweden,
  USA
}

Tente sempre usar enums de string

A partir do anterior, tente sempre usar enums de string para evitar o erro da declaração false como eu mostrei antes com o caso do if que sempre seria 0.

Use const enums sempre que possível

O TypeScript tem um outro tipo (que inclusive é bem explicado nesse artigo de um colega aqui da Klarna), que são os const enums.

const enum Country {
  Germany = 'DE',
  Sweden = 'SE',
  USA = 'US',
}

Const enums não geram código em tempo de execução. Então, adicionar:

const enum Country {
  Germany = 'DE',
  Sweden = 'SE',
  USA = 'US',
}

Não vai gerar código em runtime, mas você também não será capaz de usar o enum como um objeto, o que significa que você não será capaz de usá-los para extrair chaves ou valores, porque eles realmente não existem no código de produção.

O que eu uso

Eu tendo a fazer uma ordenação:

  1. Sempre tento usar const string enums, como a gente viu agora aqui em cima
  2. Se eu vou usar as chaves, seja listando ou fazendo alguma coisa com uma lista de chaves e valores do Enum, eu troco const enums por enums de strings
  3. Se eu preciso muito passar uma string para uma função, e a conversão ou mapeamento da string para o enum é muito complicada, eu uso POJOs (mas eu evito ao máximo)

E você? O que está usando? Comenta esse artigo com a galera e me fala lá no meu Twitter o que você acha disso!

E, se você quiser aprender mais sobre enumeradores e todas as nuances que eles trazem, na Formação TS a gente tem um capítulo só sobre eles!