type: la unión etiquetada como herramienta de modelado
Todos los tipos vistos hasta aquí responden a la conjunción: un record dice hay un número y un texto y un booleano, y por eso se llaman tipos producto. Pero la otra mitad del modelado del mundo es disyuntiva —un pago es con tarjeta o por transferencia o en efectivo— y esa alternativa cerrada no se puede expresar con un record sin dejar la puerta abierta a combinaciones absurdas. Esta lección introduce la palabra clave type de Elm, que declara una unión etiquetada o tipo suma: un tipo nuevo cuyos valores son exactamente las alternativas enumeradas, cada una marcada con un constructor que la distingue. Desarrolla la sintaxis, la diferencia crucial entre type y type alias, el constructor como etiqueta discriminante y como valor de primera clase, y el criterio de modelado que sustituye banderas booleanas y cadenas de texto libres por alternativas nombradas y cerradas. Cierra con el álgebra de tipos —por qué producto y suma son literalmente multiplicación y adición de cardinalidades— y por qué la ausencia de sumas en los lenguajes mayoritarios explica media historia de los bugs de estado.
Hasta ahora todos los tipos que has construido en Elm han respondido a la conjunción. Un record con tres campos dice: hay un número y un texto y un booleano. Una tupla dice lo mismo con menos ceremonia. Son tipos producto, y ese nombre no es una metáfora poética sino una cuenta exacta que veremos al final. Pero la mitad del modelado del mundo no es conjuntiva sino disyuntiva. Un pago es con tarjeta o por transferencia o en efectivo. Una petición está pendiente o ha terminado bien o ha fallado. Una figura es un círculo o un rectángulo. Esa segunda mitad —el o exclusivo, la alternativa cerrada— no cabe en un record, y forzarla a caber produce exactamente los diseños defectuosos que este nivel viene a desmontar. La palabra clave type es la respuesta de Elm: declara un tipo nuevo cuyos valores son exactamente las alternativas que enumeras, cada una marcada con una etiqueta que la separa sin ambigüedad de las demás. Se llama unión etiquetada, o tipo suma, y es sin exageración la herramienta de modelado más importante del lenguaje.
- Distinguir un tipo producto —
record, tupla— de un tipo suma declarado contype. - Leer y escribir la sintaxis de
typecon varios constructores y entender que el conjunto es cerrado. - Diferenciar
type, que crea un tipo nuevo, detype alias, que solo pone un nombre a uno existente. - Sustituir banderas booleanas y cadenas de texto libres por alternativas nombradas al modelar un dominio.
El eje que faltaba: de la conjunción a la disyunción
Un record combina información: para tener un valor necesitas todos sus campos a la vez. Un tipo suma hace lo contrario: para tener un valor basta con uno de sus casos, y solo uno. Son los dos ejes ortogonales del modelado de datos, y cualquier estructura que sepas describir se compone de ellos en alguna proporción. Elm te dio el primero desde el principio; el segundo llega con type.
-- Producto: hay un nombre Y una edad Y un correo, todos a la vez
type alias Usuario =
{ nombre : String
, edad : Int
, correo : String
}
-- Suma: es exactamente uno de estos tres casos, nunca dos, nunca ninguno
type MetodoPago
= Tarjeta
| Transferencia
| Efectivo
Cada nombre a la derecha del = y de las barras verticales es un constructor. Un constructor cumple dos funciones simultáneas que conviene no confundir: es la etiqueta que discrimina el caso y es, él mismo, un valor del tipo. Escribir Tarjeta no llama a nada ni ejecuta nada; produce un valor de tipo MetodoPago cuya única propiedad interesante es ser distinguible de Transferencia y de Efectivo. No hay un número oculto detrás, ni una cadena de texto, ni un índice: la identidad del caso es la etiqueta.
Y el conjunto es cerrado. Fuera de esas tres alternativas no existe ni puede existir un cuarto valor de tipo MetodoPago; no hay forma de fabricar uno desde otro módulo, ni de convertir un String en uno por descuido, ni de que llegue uno inesperado desde el exterior. Esa clausura es lo que permitirá, en la lección tercera, que el compilador verifique que has tratado todos los casos: solo se puede exigir exhaustividad sobre un conjunto que se sabe completo.
La distinción se malinterpreta a menudo y tiene consecuencias reales. type alias Edad = Int no crea nada: Edad e Int son la misma cosa con dos nombres, se pueden intercambiar libremente y sumar una Edad a un código postal compila sin queja. type Edad = Edad Int, en cambio, crea un tipo genuinamente nuevo que el compilador se niega a confundir con Int aunque por dentro lleve uno. El primero es azúcar de legibilidad; el segundo es una barrera de tipos. Cuando quieras que el compilador impida mezclar dos cosas que casualmente se representan igual, type alias no te servirá para nada y type lo hará gratis.
Modelar con alternativas en lugar de con banderas
El valor práctico de los tipos suma se ve mejor por contraste, observando qué ocurre cuando no se tienen. Sin ellos, el modelador recurre a dos sustitutos pobres: banderas booleanas y cadenas de texto. Ambos comparten el mismo defecto estructural, que es admitir muchos más valores de los que el dominio tiene sentidos.
-- Antipatron: banderas independientes para estados que se excluyen
type alias Pedido =
{ enviado : Bool
, entregado : Bool
, cancelado : Bool
}
-- Nada impide construir esto, y no significa absolutamente nada
absurdo : Pedido
absurdo =
{ enviado = False, entregado = True, cancelado = True }
Tres booleanos admiten ocho combinaciones. El dominio real tiene cuatro estados, y los cuatro se excluyen mutuamente. Las otras cuatro combinaciones son basura sintácticamente válida que el compilador acepta sin pestañear y que tú tendrás que defender a mano, con comprobaciones dispersas por toda la aplicación y comentarios que ruegan no poner los dos a True. La variante con String es peor todavía, porque el número de valores posibles pasa de ocho a infinito y una errata como "entregdo" es un valor perfectamente legal que ninguna herramienta detecta.
-- Modelado con alternativas: existen los cuatro estados reales y ni uno mas
type EstadoPedido
= Recibido
| Enviado
| Entregado
| Cancelado
El cambio no es cosmético. Antes tenías un tipo con ocho habitantes de los que cuatro eran mentira; ahora tienes un tipo con cuatro habitantes, todos verdaderos. La categoría entera de errores que consistía en dos banderas contradictorias ha dejado de ser un bug para pasar a ser algo que no se puede ni escribir. Y como el conjunto es cerrado y nombrado, la definición se lee como documentación que el compilador mantiene honesta: quien abra el archivo dentro de un año sabrá en cuatro líneas qué le puede pasar a un pedido.
flowchart TD T[type EstadoPedido] --> A[Recibido] T --> B[Enviado] T --> C[Entregado] T --> D[Cancelado] A --> U[Un valor es exactamente uno de los cuatro] B --> U C --> U D --> U style T fill:#f9e2af,color:#11111b style U fill:#a6e3a1,color:#11111b
type
Declara un tipo nuevo enumerando sus constructores. El conjunto de valores es cerrado y el compilador lo sabe.
Constructor
La etiqueta que discrimina el caso y, a la vez, un valor de primera clase del tipo. No es un número ni un texto disfrazado.
Producto
El record y la tupla: todos los campos a la vez. La conjunción del modelado, y la multiplicación de sus cardinalidades.
Suma
La unión etiquetada: uno de los casos, y solo uno. La disyunción del modelado, y la adición de sus cardinalidades.
Los dos ejes no compiten entre sí; se anidan. Un tipo suma puede tener variantes que lleven productos dentro, y un producto puede tener campos cuyo tipo sea una suma. El modelado real alterna ambos según lo que el dominio pida en cada nivel, y saber en cuál de los dos ejes estás en cada momento es buena parte del oficio.
-- Suma en el nivel exterior, producto dentro de una de sus variantes
type Sesion
= Anonima
| Iniciada Credenciales
type alias Credenciales =
{ usuario : String
, caducidad : Int
}
Un String de usuario y una caducidad solo tienen sentido cuando la sesión está iniciada, y por eso viven dentro de esa variante y no al lado de ella. Si estuvieran sueltos en un record junto a una bandera, el tipo permitiría una sesión anónima con credenciales, que es literalmente una contradicción. Colocar cada dato en el nivel correcto del anidamiento es la operación que la cuarta lección de este nivel convertirá en método.
Nombrar es modelar
Hay una consecuencia menos técnica y más profesional de todo esto. Un tipo suma obliga a nombrar cada alternativa, y nombrar obliga a decidir. Cuando escribes type EstadoPedido y empiezas a enumerar constructores, estás haciendo análisis de dominio con el compilador delante: cada caso que añades es una afirmación sobre el mundo que se puede discutir con quien conoce el negocio. Los booleanos permiten posponer ese trabajo indefinidamente, y esa comodidad inicial es justamente la trampa, porque la decisión no desaparece: se difiere y se dispersa por cien condicionales que nadie volverá a leer juntos.
El criterio de diseño que sale de aquí es sencillo de enunciar y sorprendentemente exigente de aplicar. Cada vez que estés a punto de introducir un Bool en un modelo, pregúntate qué dos situaciones representa y si de verdad son solo dos; y cada vez que introduzcas dos booleanos relacionados, sospecha en firme, porque casi siempre son una suma de tres o cuatro casos disfrazada. La sospecha se generaliza: un String que solo debería tomar ciertos valores, un Int que codifica un estado, un campo llamado tipo o modo son todos síntomas del mismo hueco, que es una unión etiquetada que alguien no pudo o no quiso declarar.
Un detalle práctico que conviene saber pronto: en Elm los constructores no se cualifican con el nombre del tipo, sino que viven directamente en el módulo. Eso significa que dos tipos distintos del mismo módulo no pueden compartir el nombre de un constructor, y explica los prefijos que verás en código real, del estilo EstadoCargando frente a PeticionCargando. También significa que al exponer un tipo desde un módulo puedes elegir entre exponerlo con todos sus constructores o solo con su nombre; esa segunda opción, el tipo opaco, es una herramienta de encapsulación que reaparecerá en la cuarta lección de este nivel.
Los nombres producto y suma son literales, no analogías, y entenderlo cambia cómo se mira un modelo de datos. Cuenta cuántos valores distintos admite un tipo y llámalo su cardinalidad: Bool tiene dos, un tipo suma de cuatro constructores sin datos tiene cuatro, un record con dos campos booleanos tiene dos por dos igual a cuatro. La regla es exacta: la cardinalidad de un producto es el producto de las cardinalidades de sus componentes, y la de una suma es su adición. Por eso tres booleanos independientes son ocho valores y cuatro constructores son cuatro, y por eso la refactorización de la sección anterior no fue una cuestión de gusto sino una reducción medible de ocho a cuatro, es decir, la eliminación exacta de las cuatro combinaciones que no significaban nada. Con esa lente, modelar deja de ser una actividad estética y se convierte en aritmética aplicada: el objetivo es que la cardinalidad del tipo coincida lo más ajustadamente posible con el número de situaciones reales del dominio, porque cada valor sobrante es un estado ilegal que alguien tendrá que defender a mano en tiempo de ejecución. Y aquí está el hecho histórico que lo enmarca todo: la inmensa mayoría de los lenguajes que dominaron la industria durante décadas —C, Java, Python, JavaScript, Go— dieron a sus usuarios productos de primera clase, con struct, clases y objetos, pero no sumas. El enum de C es una suma degenerada sin datos y sin comprobación real, la unión de C es una suma sin etiqueta y por tanto sin seguridad, y la herencia orientada a objetos se usó durante veinte años como sustituto de las sumas precisamente porque no había otra cosa, con el resultado de que la lista de subclases queda abierta y ninguna comprobación de exhaustividad es posible. La consecuencia práctica es que generaciones enteras de programadores aprendieron a modelar alternativas con lo único que tenían: banderas, códigos numéricos, cadenas mágicas, campos que solo son válidos si otro campo tiene cierto valor, y el infame null como forma pobrísima de decir o no hay nada. Media historia de los bugs de estado de la industria es la historia de esa carencia. Que Elm, Haskell, OCaml, Rust, Swift y las uniones discriminadas de TypeScript hayan convergido en devolver la suma al centro del lenguaje no es una moda funcional; es la corrección de una asimetría que nunca tuvo justificación teórica, porque el mundo se describe con conjunciones y disyunciones a partes iguales y un lenguaje que solo te da la mitad te obliga a improvisar la otra con parches. Cuando dentro de dos lecciones veas al compilador negarse a compilar por un caso que olvidaste, recuerda que esa capacidad no viene del case: viene de que el tipo era una suma cerrada, y por tanto contable.
- Declara
type MetodoPagocon tres constructores y escribe una función que devuelva unStringdescriptivo para cada uno; observa que no necesitas ningún valor por defecto. - Toma el
recordde tres banderas booleanas de la lección y enumera sus ocho combinaciones; señala cuáles son estados reales y cuáles basura, y explica quién las defiende hoy en tu código. - Reescribe ese
recordcomo untypede cuatro constructores y cuenta cuántos estados ilegales han dejado de ser expresables. - Escribe
type alias Edad = Intytype Anios = Anios Int, e intenta sumar cada uno a un entero cualquiera; explica con precisión qué hace distinto el compilador en cada caso. - Busca en un proyecto propio un campo de texto libre que solo admite unos pocos valores y conviértelo en un tipo suma; anota qué erratas se vuelven imposibles.
- Calcula la cardinalidad de un
recordcon unBooly un tipo suma de tres constructores, y luego la de un tipo suma de dos constructores donde cada uno lleva eseBool; comprueba que la aritmética coincide con tu intuición.