En el artículo de hoy espero arrojar un poco de claridad sobre un tema controvertido: Los enumerados.
Y es que, según lo claros que tengamos ciertos conceptos y los flexibles que queramos ser, acabaremos usando “enums” que son más parecidos a objetos de mapeo que a otra cosa.
He tenido diversas discusiones sobre el tema, y al final veo que hay un problema de facto en entender exactamente que es un enum por fijarnos demasiado en la implementación concreta que se suele hacer en Javascript.
En este artículo veremos los tipos de implementaciones y usos y las ventajas que perdemos al alejarnos de la definición de un enumerado.
Qué es un enum
Un enum o enumerado es un tipo de dato que nos permite definir un conjunto de valores nombrados que se comportarán como constantes. Los valores de cada miembro del conjunto deben de permitir la comparación y la asignación.
Es decir, los enumerados nos permiten agrupar diferentes valores en un conjunto, con la idea de representar los valores predefinidos o estados que puede tomar un programa.
Por ejemplo, imaginamos que tenemos que representar los diferentes estados en los que podemos encontrarnos, como persona. Podríamos definir un conjunto enumerado llamado “Estado de ánimo” y que agrupase los valores: “alegre”, “triste”, “enfurecida”, etc.
El nombre “enumerado” viene precisamente de enumerar los valores que pertenecen a un conjunto determinado. Así, cuando hablemos de “triste” sabemos que ese valor pertenece al conjunto “Estado de ánimo”.
Para podernos aprovechar de todas las ventajas de los enums, el cómo se identifican valores en un conjunto debe de ser irrelevante tanto para el que define el enumerado como para la que lo usa.
Uso de enumerados
En Javascript no existe el concepto de enumerado, pero puede ser fácilmente implementado usando objetos planos. Sin embargo, es importante mantener unas reglas para no alejarnos de la definición anteriormente expuesta.
La mayoría de las programadoras que usan y promueven los enums en Javascript, no lo implementan como enumerados, sino como objetos de mapeo que no aprovechan las ventajas reales de los enumerados.
Antes de empezar a implementar, hay que tener en cuenta las siguientes premisas:
En un enum, lo más importante es el valor del conjunto.
Los valores se han de tratar como tipos. “Alegre” es un tipo del conjunto “Estado de ánimo”.
El conjunto no debe de poderse modificar una vez definido.
Los valores del conjunto deben de ser únicamente identificados (para poder realizar comparaciones).
Dicho esto, podemos empezar a implementar un enum en Javascript utilizando objetos planos:
const EstadoDeAnimo = {
ALEGRE: 'ALEGRE',
TRISTE: 'TRISTE',
}
Como en Javascript no existe una implementación nativa de enumerados, hemos usado un objeto normal para simular este comportamiento.
Aquí, el objeto EstadoDeAnimo representa el conjunto y las propiedades son los valores de ese conjunto.
En este momento, suele venir la primera controversia. Cuando hablamos de valores, es común pensar que nos referimos al valor de una clave, pero nos referimos a la clave en si misma, el valor no es importante:
Nosotros tenemos que trabajar siempre con los valores del conjunto, siendo los ids de los valores, identificadores que identifican de forma única a cada clave.
Recuerda que los objetos en Javascript se definen como pares clave/valor de propiedades. Cuando los usamos para definir enumerados, la clave es el valor del conjunto y el valor es el identificador de esa clave.
Esto es superimportante porque implica que el valor de la clave (id del valor del conjunto) debe de ser un valor subyacente al nombre de la clave, es decir si definimos una propiedad ‘ALEGRE’ como valor del conjunto de EstadoDeAnimo, el identificador debe de estar relacionado con ese nombre. Por eso, suele ser buena práctica que clave y valor tengan el mismo nombre.
Esto nos permite relacionar valor e id como miembros de un conjunto determinado con el objetivo de poder realizar comprobaciones en el futuro:
function greet(mood) {
if (mood === EstadoDeAnimo.ALEGRE) {
console.log('Bueeeenasss');
}
console.log('Buenos días, serán para ti');
}
greet(EstadoDeAnimo.TRISTE); usamos clave
greet('ALEGRE'); usamos id de clave
Tanto al usar la clave del conjunto directamente como su identificador, la función de arriba devuelve el resultado correctamente.
Trabajando con los enums de esta forma maximizamos la reutilización, ya que hoy estamos usando el enum EstadoDeAnimo para generar mensajes de saludo, pero mañana podríamos usar el mismo enum para, por ejemplo, generar una clase CSS para algún componente web.
Estrechando tipos (Narrowing types)
Esta técnica es muy común en lenguajes tipados porque nos permite reducir los valores que puede tomar un programa o función. Con los enums que hemos definido arriba, podemos aplicar esta técnica sin despeinarnos.
Si solo trabajamos con claves en nuestro conjunto, utilizando los valores como identificadores únicamente, ganaremos el control sobre qué hacer con cada valor en cada momento:
const Size = {
SMALL: 'SMALL',
MEDIUM: 'MEDIUM',
LARGE: 'LARGE',
}
Definimos un conjunto llamado Size y definimos los valores SMALL, MEDIUM, LARGE como miembros de ese conjunto.
Ahora podemos definir una función que opere sólo con valores de ese conjunto:
function getInputSizeClassName (size) {
switch(size) {
case Size.SMALL: return '.input-small';
case Size.MEDIUM: return '.input-medium';
case Size.LARGE: return '.input-large';
}
}
Nuestra función getInputSizeClassName solo opera con valores definidos en el conjunto de Size, por lo tanto, si le pasamos algo que no sea un Size, la función NO devolverá un valor válido.
Alternativamente, podemos agregar un control sobre valores fuera del conjunto, proporcionando un caso por defecto:
function getInputSizeClassName (size) {
switch(size) {
case Size.SMALL: return '.input-small';
case Size.MEDIUM: return '.input-medium';
case Size.LARGE: return '.input-large';
default: return '';
}
}
Y si usamos la función ahora:
getInputSizeClassName(Size.LARGE) // '.input-large'
getInputSizeClassName('OTRA') // ''
De esta forma estamos “cerrando” la función a una serie de valores. No obstante, y al ser Javascript un lenguaje no estricto a nivel de tipos, no estamos cerrando la función a valores del conjunto Size, por lo que nada impediría a un desarrollador usar la función de la siguiente forma:
getInputSizeClassName('LARGE') // '.input-large'
Esto rompe un poco la ventaja de los enums de acotar entradas a valores en un conjunto, ya que, aunque el string ‘LARGE’ pertenezca al conjunto que hemos definido, no hemos tenido que hacer referencia a él, por lo que si en el futuro renombrados LARGE a otra cosa, tendremos que actualizar todos los usos a ese nuevo nombre.
Para solucionar esto, podemos definir el enum con ids referenciados únicos para poder tener una limitación de valores en el conjunto de forma estricta, y no estructural como ocurre ahora:
const Size = {
SMALL: Symbol('SMALL'),
MEDIUM: Symbol('MEDIUM'),
LARGE: Symbol('LARGE'),
}
Usando Symbols creamos valores referenciados únicos y obligamos a los usuarios de nuestro código a utilizar las mismas referencias de nuestro conjunto, no pudiendo usar valores estructuralmente parecidos:
getInputSizeClassName('LARGE') // ''
getInputSizeClassName(Size.LARGE) // '.input-large'
Todo depende del nivel de “estrechamiento” en el uso que queramos tener.
Hay quien ve este tipo de controles como algo “contraproducente” en lenguajes de tipado débil, sin embargo, a mí me parece una buena forma de generar determinismo en el uso. Así, si el usuario de una función solo tiene acceso a un conjunto de valores, será más sencillo que podamos agregar nueva funcionalidad o aplicar cambios en un futuro.
Trampas habituales
Hasta aquí todo bien, si usamos los enums como los hemos descrito arriba vamos a obtener automáticamente las siguientes ventajas:
Acotamos los valores que puede tomar nuestro programa a un conjunto determinado.
Aportamos semántica a esos valores.
Desacoplamos la clave del identificador, por lo que cambios a futuro en los mismos no romperá nuestro programa.
Sin embargo, hay ciertas variaciones de esta implementación que llevan a pervertir el concepto de los enums y anulan sus ventajas.
No tratar el valor de la clave como un identificador
Esta es la trampa más común y anula directamente la ventaja más importante de los enumerados. Y es que, si el valor de la clave no subyace a esta, caeremos en el error de usar nuestro “enum” como si fuera un objeto de mapeo (de clave a valor) no pudiendo cerrar nuestro programa a datos de ese conjunto.
Veamos el siguiente ejemplo:
const Status = {
ACTIVE: '.input-active',
DEACTIVE: '.input-deactive',
};
En apariencia es similar al objeto que hemos usado antes para definir el enum de “Size”. Sin embargo, al otorgar de responsabilidad de dominio al identificador de las claves, caeremos en el error muy probablemente de hacer algo así:
const Input = (status = Status.ACTIVE) => <input className={status} />;
Aquí no estamos usando cada valor del conjunto de Status como un tipo que después usemos para comparar, sino que estamos usando directamente los valores como si estos perteneciesen a nuestro dominio.
Esto no es un “enum”, es un objeto de mapeo que dada una clave (ACTIVE, DEACTIVE) devuelve una clase de CSS. Pero lo más importante, es que no cierra los posibles valores al conjunto que hemos definido, pudiendo hacer directamente:
<Input status="mi-otra-clase" />
Pervertimos el valor de la propiedad status y el programa no se queja, perdiendo la ventaja principal que nos ofrecían los enums: acotar los valores que puede tomar nuestro programa a un conjunto determinado.
De esta forma perdemos la posibilidad de hacer narrowing, y reducimos el objeto a un conjunto de valores de dominio constantes.
Lamentablemente, este ejemplo es bastante común. Si mañana queremos usar el Status para otra cosa que no sean clases CSS, caeremos en la duplicidad de tipos o peor realizar comprobaciones extra y combinaciones con otros objetos de mapeo.
Mutabilidad
Con esto nos referimos al poder modificar los valores del conjunto una vez definido, lo que generará con bastante seguridad bugs en nuestro código:
const Status = {
ACTIVE: 'ACTIVE',
DEACTIVE: 'DEACTIVE',
}
function getClass(status) {
switch(status) {
case 'ACTIVE': return '.active'
}
}
STATUS.ACTIVE = "A"
getClass(Status.ACTIVE) // undefined
Al poder modificar el identificador de una clave a posteriori, nuestras comparaciones se romperán.
La forma más sencilla de arreglar esto, es atenerse a lo que he comentado arriba: no trabajar con los identificadores directamente, sino solo con sus claves:
function getClass(status) {
switch(status) {
case Status.ACTIVE: return '.active'
}
}
STATUS.ACTIVE = "A"
getClass(Status.ACTIVE) // '.active';
Otra opción (que no es excluye a esta) podría estar en congelar el objeto que hace las veces de enumerado, para así evitar su modificación en el futuro:
const Status = Object.freeze({
ACTIVE: 'ACTIVE',
DEACTIVE: 'DEACTIVE',
})
Status.ACTIVE = "A" // no tiene efecto
Status.ACTIVE // 'ACTIVE'
Así las mutaciones sobre el objeto enumerado carecerán de efecto.
Utilizar números como identificadores
Otro error común es creer que la palabra “enumerado” viene del hecho que cada valor dentro de un conjunto se identifica con un número entero. Es falso. El nombre enumerado hace referencia a la capacidad de listar valores de un conjunto, no tiene nada que ver con su identificación.
Dicho esto, es cierto que históricamente en algunos lenguajes se han usado enteros como identidad para los miembros de un conjunto. Por ejemplo, en C++ los miembros de un enumerado se identifican con enteros, siendo 0 el primer elemento y N el último.
En otros lenguajes como Java, cada valor del enum es un objeto por lo que cierra mucho el tipo de uso que se le pueden dar, yendo más en la dirección de lo qu es un enumerado real.
Aunque algunos lenguajes hayan definido enteros como identificadores de valores, siempre ha existido la buena práctica de no realizar comprobaciones directamente con enteros, puesto que dificultan mucho el entendimiento del código.
Existen desarrolladores que aún se basan en el mecanismo por defecto de identificación numérica para, por ejemplo, persistir directamente un enum en base de datos o devolver los valores ordinales del mismo como resultado de un servicio.
Es un error, un mal uso de los enumerados y manifiesta un problema de entendimiento.
No usar explícitamente los identificadores de un enum en cualquier lenguaje, nos alejará de este tipo de problemas. Lo más importante de un conjunto de valores, son los valores, no el cómo se identifican los mismos.
Es por eso que yo siempre recomiendo enumerados en Javascript cuyos ids sean Symbols y así evitar caer en la trampa de usar los valores sin pasar por un mapper previo.
Conclusión
Un enum es un mecanismo sencillo de agrupar valores y proporcionar determinismo a los usuarios de nuestras funciones. No obstante, si no prestamos atención al cómo lo implementamos en Javascript, sacrificaremos muchas de sus ventajas.
Como normal general, si para ti lo más importante de un “enum” son los identificadores (valores de claves), probablemente no estes usando un enumerado y estes más cerca de tener mappers o objetos con valores constantes. Y esto no es malo, ojo, pero hay que llamar a cada cosa por su nombre para no llevar a otras personas a error.
Mantén tus enums con valores identificados mediante referencias (como symbols) y transformando esos valores a lo que necesites según el caso y podrás aprovecharte de todas las ventajas que hemos visto hoy.
Esto cobra muchísimo sentido cuando hacemos APIs públicas y tenemos que darles a nuestros clientes conjuntos cerrados y definidos de valores que pueden tomar cada uno de los parámetros.
Nada más por hoy :)