Qué dicen los enums en C, Java, Rust, Go y TypeScript sobre el diseño de tipos
Compare cómo siete lenguajes definen los enums, desde enteros con nombre hasta uniones etiquetadas y objetos en tiempo de ejecución, y entienda qué implica eso para el modelado en TypeScript.
Fuentes: Enums Were Never Just Constants
Casi todos los lenguajes principales cuentan con algo llamado enum, y casi cada uno de ellos lo define de manera diferente. En un lenguaje es una etiqueta sencilla sobre un entero, en otro es una clase con métodos, en otro más es una unión de datos de formas distintas, y en TypeScript es una de las pocas construcciones a nivel de tipo que supera al JavaScript tradicional. Analizar C, C++, Java, Rust, Go, TypeScript y Odin lado a lado hace que esas diferencias sean concretas y te proporciona un vocabulario más preciso para decidir qué utilizar en tu propio código TypeScript cuando un valor solo puede ser uno de varios posibles.
Una palabra, varios problemas separados
La imagen típica es sencilla: se enumeran unos pocos nombres, y cada nombre representa uno de los valores permitidos:
Red
Green
Blue
Parece ser un concepto universal, pero en cuanto se comparan sus implementaciones, se dividen:
- C trata a un enum principalmente como un grupo nombrado de constantes enteras.
- C++ mantiene ese modelo pero agrega
enum class, lo que introduce ámbitos de definición y un tipo más estricto. - Java convierte cada constante enum en un objeto real de una clase enum, con campos, constructores, métodos e incluso cuerpos de clase específicos para cada constante.
- Rust permite que un único tipo enum almacene variantes que contienen datos completamente diferentes.
- Go no tiene la palabra clave
enum; en su lugar ofrece constantes y el identificadoriota. - Los enums de TypeScript se compilan en objetos JavaScript que existen en tiempo de ejecución.
- Odin mantiene los enums de forma deliberadamente sencilla y delega las tareas relacionadas a otras características, como conjuntos de bits y arrays indexados por enum.
La razón por la que no existe una definición única es que “enum” es en realidad un conjunto de problemas que coinciden al compartir una palabra clave. El diseñador del lenguaje debe decidir qué aspectos de los siguientes debería abarcar esta estructura:
- asignar nombres a un conjunto fijo de valores
- introducir un tipo distinto en lugar de reutilizar uno existente
- vincular datos a valores individuales
- representar varios estados posibles de un valor
- permitir que el compilador demuestre que se ha gestionado cada caso
- representar de forma compacta combinaciones de flags
- decidir qué, si es que algo, permanecerá en el programa en ejecución
Cada lenguaje elige un subconjunto diferente, y estas elecciones revelan mucho sobre sus prioridades.
C: nombres legibles para enteros
La enumeración tradicional C es una conveniencia para nombrar constantes enteras. Un tipo de estado podría declararse de esta manera:
enum Status {
STATUS_OK,
STATUS_ERROR,
STATUS_PENDING
};
En realidad, los tres nombres corresponden simplemente a enteros consecutivos que comienzan en cero:
0
1
2
Puede parecer trivial, pero resuelve un problema real de legibilidad. Imagine una comparación con un número simple enterrado en la lógica de configuración:
if (status == 2) {
...
}
Nada en esa línea indica al revisor qué representa 2. La misma verificación escrita con una constante nombrada se documenta por sí sola:
if (status == STATUS_PENDING) {
...
}
Esta es la justificación más antigua de las enumeraciones: reemplazan los números mágicos por nombres que transmiten intención. También combinan naturalmente con switch, donde cada caso se interpreta como una afirmación sobre el dominio en lugar de sobre aritmética:
switch (status) {
case STATUS_OK:
...
break;
Los casos restantes siguen el mismo patrón:
case STATUS_ERROR:
...
break; case STATUS_PENDING:
...
break;
}
Dado que los valores son enteros comunes y predecibles, los enums en C se integran perfectamente en el código de bajo nivel: se corresponden con registros de hardware, campos de protocolo y formatos de archivo sin sorpresas.
El compromiso es una implementación débil. C no trata a un enum como un conjunto cerrado como lo hace Rust. Las constantes de enumeración se comportan como enteros; por lo general, se puede asignar a una variable enum cualquier entero sin problemas, y el compilador no tiene la obligación de señalar la ausencia de un case. Muchos compiladores emiten advertencias para valores de enum no manejados en un switch, pero eso es una cortesía de la herramienta y no una garantía del lenguaje. C23 sí añadió sintaxis para declarar un tipo subyacente fijo, lo que reduce una brecha que C++ ya había cerrado años antes.
La filosofía de C puede resumirse así:
Dar a los enteros nombres más adecuados y mantener al mínimo la infraestructura que los rodea.
En la programación de sistemas, ese minimalismo es a menudo exactamente lo que se desea.
C++: la misma idea con límites más estrictos
C++ heredó los enums de estilo C y posteriormente introdujo una forma más estricta, la enumeración en ámbito limitado:
enum class Status {
Ok,
Error,
Pending
};
El cambio no es solo un detalle sintáctico. Los enumeradores de una enumeración en ámbito limitado deben especificarse con el nombre del tipo:
Status::Ok
en lugar de aparecer como un identificador simple en el ámbito circundante:
Ok
Igualmente importante, una enumeración en ámbito limitado no se convierte implícitamente en un entero. No se puede comparar accidentalmente un Status con un Color no relacionado ni pasarlo donde se espera un int sin un cast explícito, lo que evita todo tipo de confusiones silenciosas.
C++ también permite elegir explícitamente el tipo de almacenamiento:
enum class Status : uint8_t {
Ok,
Error,
Pending
};
Eso es importante siempre que el diseño forme parte del contrato, por ejemplo en paquetes de red o estructuras compartidas con código C. El lenguaje distingue entre enums sin ámbito y aquellos con ámbito declarados con enum class o enum struct, y ambas formas aceptan un tipo subyacente fijo.
El patrón que merece atención es que C++ no redefine qué es un enum. Agrega control sobre tres aspectos relacionados con él:
- cómo se define el ámbito de los nombres
- qué tan estrictamente se separa el tipo de los enteros
- cómo se representa el valor en memoria
En esencia, sigue siendo una lista de valores nombrados; el programador simplemente puede delimitarlo con mayor precisión.
Java: enums como parte del modelo de objetos
Java se aleja aún más de los enteros. La declaración tiene un aspecto familiar:
enum Status {
OK,
ERROR,
PENDING
}
No obstante, OK, ERROR y PENDING no son alias numéricos. Según la Especificación del Lenguaje Java, un enum es una forma especial y restringida de clase cuyas instancias se declaran todas por nombre de antemano. Cada constante es un objeto, creado una sola vez al inicializar la clase, y como es un objeto puede contener estado y comportamiento.
Constantes que almacenan datos
Un argumento del constructor puede asignar un valor a cada constante. Aquí, cada planeta registra su masa:
enum Planet {
EARTH(5.97),
MARS(0.642);
El campo y el constructor que lo popula completan la declaración:
final double mass; Planet(double mass) {
this.mass = mass;
}
}
Ahora cada constante viaja con sus propios datos, y el código que necesita la masa de un planeta lee un campo en lugar de consultar una tabla de búsqueda separada que podría desincronizarse.
Constantes que almacenan comportamiento
Los enums también pueden declarar métodos, y cada constante puede proporcionar su propia implementación. El siguiente tipo de operación sobrescribe un método abstracto apply por constante:
enum Operation {
ADD {
@Override
int apply(int a, int b) {
return a + b;
}
},
La segunda constante y la declaración abstracta se ven así:
MULTIPLY {
@Override
int apply(int a, int b) {
return a * b;
}
}; abstract int apply(int a, int b);
}
En este punto, el enum es claramente una clase, y eso es intencional. Java integra los enums en su modelo orientado a objetos, por lo que una constante puede tener campos, argumentos de constructor, métodos compartidos y sobrescrituras específicas para la constante. Una constante con su propio cuerpo de clase se compila como una subclase anónima del tipo enum, y en las versiones recientes de Java la especificación describe a dicha clase enum como implícitamente sellada, ya que el conjunto de subclases está fijado por la declaración.
Por lo tanto, la postura de Java es más o menos:
Si un valor proviene de un conjunto fijo, ¿por qué restringirlo a un entero cuando podría tratarse de un objeto con su propio comportamiento?
Eso hace que las enumeraciones de Java sean muy adecuadas para máquinas de estado, analizadores, tablas de operadores con precedencia y mecanismos de despliegue de tipo estrategia, donde cada opción sabe cómo realizar su propio trabajo.
Cuando la estructura de los datos depende de la variante
Las enumeraciones de Java brillan cuando cada constante contiene el mismo tipo de información predefinida. Surge un problema diferente cuando cada alternativa necesita una estructura distinta.
Considere los mensajes en un protocolo de chat:
TextMessage
ImageMessage
VideoMessage
Un mensaje de texto contiene una cadena, un mensaje de imagen alberga bytes de imagen y metadatos, mientras que un mensaje de video contiene algo diferente. Lo que varía aquí no es el valor de un campo, sino la estructura de los datos en sí. Un enum al estilo Java, donde cada constante comparte el mismo conjunto de campos, no es adecuado para este caso. Este es precisamente el problema para el cual están diseñados los enums en Rust.
Rust: enums como uniones etiquetadas
En Rust, las variantes de un enum pueden tener cada una su propio contenido:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(u8, u8, u8),
}
Quit no lleva nada, Move tiene campos con nombres, Write envuelve una String, y ChangeColor almacena tres bytes. Para crear un valor se elige una variante y se proporcionan sus datos:
let message = Message::Move { x: 10, y: 20 };
Otra variante del mismo tipo se ve así:
let message = Message::Write(String::from("Hello"));
Ambas expresiones generan un Message, pero la información que contienen tiene una estructura completamente diferente. Esta construcción es lo que la teoría de tipos denomina tipo suma, y lo que los programadores de sistemas suelen llamar unión etiquetada: una etiqueta indica qué variante está activa, y la carga útil alberga los datos de dicha variante. El tipo describe un conjunto cerrado de alternativas:
Message
├── Quit
├── Move { x, y }
├── Write(String)
└── ChangeColor(r, g, b)
Comprobación exhaustiva
El compilador conoce cada variante, y match aprovecha ese conocimiento. El primer caso se encarga de Quit:
match message {
Message::Quit => {
println!("Quit");
}
Los casos restantes desestructuran la carga útil de cada variante y asignan sus campos a nombres locales:
Message::Move { x, y } => {
println!("Move to {}, {}", x, y);
} Message::Write(text) => {
println!("{}", text);
} Message::ChangeColor(r, g, b) => {
println!("RGB: {}, {}, {}", r, g, b);
}
}
Si eliminas uno de esos casos, el programa ya no se compila; Rust rechaza un match que no sea exhaustivo. La documentación de Rust presenta los enums y la coincidencia de patrones juntos precisamente por esta razón. Al agregar una nueva variante posteriormente, cada match incompleto en el código se convierte en un error de compilación que indica los lugares que necesitan actualización.
Esa es la diferencia fundamental con respecto a un enum en C. Un enum en C responde:
¿A cuál de las constantes nombradas corresponde este número?
Un enum en Rust responde:
¿Qué variante está activa en este momento y qué información viene asociada a ella?
La segunda pregunta abarca un rango mucho más amplio de diseños.
Hacer que los estados inválidos no sean representables
El beneficio práctico es más fácil de observar en los resultados. Una operación que puede tener éxito o fracasar se puede modelar como un tipo con dos variantes:
Result
├── Success(value)
└── Error(error)
Un valor que puede estar presente o no recibe el mismo tratamiento:
Option
├── Some(value)
└── None
La biblioteca estándar de Rust incluye ambas como Result y Option, y se trata de enums comunes. Compárese esto con el enfoque de campos sueltos que es habitual en muchos conjuntos de código:
value = null
hasValue = false
error = ...
Con campos separados, nada impide que hasValue sea true mientras value es nulo, o que se establezcan al mismo tiempo un error y un valor. Cada usuario debe recordar las reglas no escritas. Con un tipo de suma, esas combinaciones simplemente no pueden crearse, ya que la única forma de construir un valor es a través de una de las variantes declaradas.
Eso resume uno de los propósitos más profundos del diseño de enums:
Un enum puede codificar reglas sobre la estructura de los datos directamente en el sistema de tipos.
Una vez que las reglas se encuentran en el tipo, el compilador puede detectar los casos faltantes antes de que se ejecute cualquier cosa. Los desarrolladores de TypeScript cuentan con un equivalente cercano a través de las uniones discriminadas, donde un campo literal compartido como kind desempeña el papel de etiqueta, y una verificación con never garantiza la exhaustividad.
Go: no existe ninguna palabra clave para enums
Después de Rust, el enfoque de Go puede parecer un paso atrás en el tiempo. No existe la palabra clave enum. La forma habitual de hacerlo es mediante un bloque de constantes generadas por iota:
const (
StatusOK = iota
StatusError
StatusPending
)
Dentro de una declaración de constante, iota evalúa al índice de la especificación de constante actual, comenzando en cero, y puede aparecer dentro de expresiones. Cuando una línea del bloque omite su expresión, Go repite la anterior, por lo que lo siguiente:
const (
A = iota
B
C
)
produce:
A = 0
B = 1
C = 2
Dado que la repetición implícita copia toda la expresión, iota puede hacer más que contar. Desplazar con él genera potencias de dos:
const (
Read = 1 << iota
Write
Execute
)
Los valores resultantes son:
Read = 1
Write = 2
Execute = 4
Los bits distintos convierten a estas constantes en banderas naturales: Read | Write combina permisos, y un AND bit a bit verifica uno de ellos. También se puede introducir un tipo nombrado para que las constantes no sean solo enteros sueltos:
type Permission uint8
y luego declarar las constantes con ese tipo:
const (
Read Permission = 1 << iota
Write
Execute
)
Solo Read expresa explícitamente Permission, pero como la expresión se repite, también se transmite el tipo, por lo que Write y Execute son valores de Permission igualmente. La especificación de Go distingue entre constantes tipadas y no tipadas, y un tipo nombrado brinda cierta protección contra la mezcla de valores no relacionados. Sin embargo, no crea el tipo de identidad de enum que se encuentra en lenguajes con una estructura dedicada.
Eso es característico de Go: en lugar de añadir una función específica, proporciona un sistema de constantes flexible y deja la abstracción de nivel superior al programador. El costo es que el lenguaje nunca vincula formalmente
these constants
con
this closed set of variants
de la misma manera que lo hacen Rust o Java. Cualquier valor del tipo subyacente puede convertirse en una Permission, y el compilador no verifica que un switch sobre estas constantes sea exhaustivo. Los equipos que desean esa garantía suelen añadir un linter o un generador de código adicional.
TypeScript: un enum que existe en tiempo de ejecución
TypeScript es principalmente una capa de tipos estáticos sobre JavaScript. Un alias de tipo como este:
type User = {
name: string;
};
no deja rastro en el JavaScript generado; se elimina durante la compilación. Los enums rompen esa regla:
enum Direction {
Up,
Down,
Left,
Right
}
Un enum regular de TypeScript se compila en un objeto real de JavaScript, y la guía de TypeScript documenta explícitamente este comportamiento en tiempo de ejecución. En el caso de enums numéricos, el objeto generado incluye mapeos tanto directos como inversos, por lo que se puede pasar de un nombre a un número y viceversa:
Direction.Up -> 0
Direction[0] -> "Up"
Eso sorprende a los desarrolladores que asumen que todo lo que agrega TypeScript es únicamente en tiempo de compilación. También tiene consecuencias prácticas: el enum añade código a tu paquete, y al iterar sobre un enum numérico con Object.keys se obtienen las claves numéricas como cadenas junto con los nombres.
Los enums de cadena asignan valores de cadena explícitos:
enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT"
}
No cuentan con mapeos inversos, y sus valores son mucho más fáciles de leer en registros, cargas de red y salida del depurador, ya que "UP" se entiende por sí mismo mientras que 0 no. TypeScript también admite miembros calculados y const enum, el cual se incluye directamente en los puntos de uso y se elimina de la salida. Ten en cuenta que const enum interactúa mal con herramientas que compilan un archivo a la vez, por lo que muchos proyectos lo evitan.
La alternativa object-plus-as-const
La documentación de TypeScript también muestra un patrón más sencillo formado por un objeto simple y una afirmación as const:
const Direction = {
Up: 0,
Down: 1,
Left: 2,
Right: 3,
} as const;
Se trata de JavaScript ordinario que cualquier herramienta comprende, mientras que as const proporciona tipos literales a TypeScript para cada propiedad. Se puede derivar un tipo de unión correspondiente con (typeof Direction)[keyof typeof Direction]. Este patrón se ha vuelto aún más relevante a medida que los entornos de ejecución han adquirido la capacidad de eliminar tipos directamente: ese enfoque solo elimina anotaciones, y los enums, que requieren generación real de código, no encajan en él. Consulte la documentación actual de su entorno de ejecución y compilador para conocer las reglas exactas; el artículo en lo que realmente hace el soporte nativo de TypeScript en Node.js aborda ese aspecto en detalle.
El punto fundamental es que el diseño de los enums en TypeScript está determinado por su entorno host. Java podría definir enums como una característica de tiempo de ejecución de primera clase desde cero. TypeScript, en cambio, debe coexistir con JavaScript, que no cuenta con enums propios. La versión de la pregunta en este caso es:
¿En qué debería transformarse una característica a nivel de tipo cuando el entorno de tiempo de ejecución es JavaScript?
No existe una respuesta clara, y por eso TypeScript terminó adoptando varias opciones que se superponen entre sí.
Odin: un enum simple más herramientas ortogonales
Odin sigue otro enfoque distinto. Sus enums se mantienen cercanos a la idea clásica de un conjunto de valores con nombres, y en lugar de convertir el enum en una jerarquía de objetos o un tipo de dato algebraico, el lenguaje ofrece características separadas para problemas relacionados: tipos distintos, arrays enumerados indexados por un enum, uniones y conjuntos de bits cuyo tipo de elemento puede ser un enum.
Un enum simple describe las direcciones del compás:
Direction :: enum {
North,
East,
South,
West,
}
Un conjunto de bits basado en ese enum representa entonces cualquier combinación de direcciones:
Direction_Set :: bit_set[Direction]
El principio de diseño es la división del trabajo:
- un conjunto fijo de valores es un enum
- una colección de esos valores es un conjunto de bits
- los datos asociados a cada valor se encuentran en un array indexado por el enum
- las alternativas con diferentes estructuras de datos son una unión
Dado que cada herramienta cumple una función específica, se puede analizar cada una por separado, y ninguna de ellas tiene que asumir la carga de las demás. Compare esto con Go, donde las banderas se crean manualmente mediante desplazamientos, o con Rust, donde el propio enum abarca tanto listas simples como uniones etiquetadas.
Ningún lenguaje ha resuelto correctamente los enums
Es tentador clasificar estos diseños, pero cada uno responde a preguntas diferentes. Una comparación concisa:
- C: constantes enteras con nombre, optimizadas para la simplicidad, legibilidad y compatibilidad de bajo nivel.
- C++: la misma base, pero con ámbito opcional, tipado más estricto y un tipo subyacente explícito.
- Java: un conjunto fijo de instancias de una clase enum, capaz de contener campos, constructores, métodos y comportamiento específico para cada constante.
iota generan valores secuenciales o calculados.Ninguno de estos es una versión mejorada del anterior. Cada uno refleja lo que sus diseñadores querían que esa característica se encargara de hacer.
Qué revela el diseño de los enums sobre un lenguaje
Los enums son lo suficientemente sencillos como para aprenderse en una tarde, pero al mismo tiempo revelan la filosofía del lenguaje que los rodea. Se pueden entender como la pregunta que parece plantear cada lenguaje:
- C: ¿cómo se puede representar esto de manera eficiente con primitivas simples?
- C++: ¿cómo podemos mantener un control cercano al hardware y, a la vez, añadir abstracciones más seguras?
- Java: ¿qué pasaría si cada elemento de un conjunto cerrado fuera un objeto completo con sus propios métodos?
- Rust: ¿puede un único tipo describir todos los estados válidos junto con el contenido de cada uno?
- Go: ¿es realmente necesaria una característica dedicada?
- TypeScript: ¿en qué se convierte una función de tiempo de compilación cuando debe ejecutarse como JavaScript puro?
- Odin: ¿puede la estructura básica permanecer sencilla mientras que características ortogonales se encargan de necesidades relacionadas?
Dado que los lenguajes se optimizan para cosas diferentes, los enums nunca han convergido en una sola idea.
Los enums son decisiones de modelado, no sintaxis
Es fácil pensar en un enum como una parte de la sintaxis:
enum {
A,
B,
C
}
La sintaxis es la parte menos interesante. La verdadera pregunta es qué quiere que garantice el sistema de tipos sobre los valores que ha enumerado:
A, B, C
Abordar esa pregunta implica responder a varias más pequeñas:
- ¿Son estos simplemente nombres para números?
- ¿Necesita cada valor datos asociados?
- ¿Necesita cada variante su propio comportamiento?
- ¿Debería el compilador saber que estos son los únicos estados válidos y asegurar un manejo exhaustivo?
- ¿Necesita banderas de bits compactas?
- ¿Deben existir los valores en tiempo de ejecución, para iteración o validación?
- ¿Cómo deben serializarse a través de la red o en el almacenamiento?
Diferentes respuestas conducen a diferentes diseños, y las consecuencias van mucho más allá de la sintaxis: el layout de memoria, la seguridad de tipos, lo que puede analizar el compilador, el costo en tiempo de ejecución, la coincidencia de patrones, la estructura de la API, el formato de serialización y, en última instancia, cómo el equipo aborda sus datos.
A veces el enum adecuado no es un enum
Esto tiene consecuencias directas en el uso cotidiano de TypeScript. Un lenguaje ofrece un enum porque existe un problema específico, pero eso no significa que todo conjunto fijo de valores deba convertirse en uno. Dependiendo de la situación:
- una unión de literales de cadena suele ser más clara y no tiene costo alguno en tiempo de ejecución
- un objeto simple con
as constproporciona valores y tipos en tiempo de ejecución sin necesidad de compilación especial - una clase dedicada tiene sentido cuando cada opción implica un comportamiento significativo
- un conjunto de bits o un entero con flags es la herramienta adecuada para combinar opciones
Un buen diseño de tipos parte de los datos y sus invariantes, no de la palabra clave que el lenguaje ofrezca por casualidad.
Puntos clave
- “Enum” agrupa varios aspectos independientes: nombrado, tipado distinto, datos asociados, formas de las variantes, exhaustividad, flags y presencia en tiempo de ejecución.
- C y C++ se centran en enteros y su representación; Java se centra en objetos; Rust se centra en tipos sum con coincidencias verificadas por el compilador.
- Go y Odin muestran dos enfoques opuestos para evitar un enum versátil: construirlo uno mismo a partir de constantes, o dividir la tarea entre características especializadas.
- Las enumeraciones de TypeScript son objetos reales en tiempo de ejecución, lo cual afecta a los paquetes, la iteración y la compatibilidad con las herramientas de eliminación de tipos; las uniones literales y los objetos
as constsuelen ser la opción más sencilla. - Antes de recurrir a una enumeración, decida qué garantías necesita. El constructo que elija debe derivarse de esas garantías, y no al revés.