wandres.dev
CUSTOM TYPES · uniones y pattern matching

Constructores con datos: cada variante lleva lo suyo

Una unión etiquetada cuyos constructores no llevan datos es poco más que un enum. Su verdadero poder aparece cuando cada variante puede transportar información propia, distinta en tipo y en cantidad de la que llevan las demás. Esta lección desarrolla los constructores con carga en Elm: la sintaxis, la lectura de un constructor como función de sus argumentos al tipo, su condición de valor de primera clase que puede pasarse a map o a un decodificador, y el ejemplo canónico del estado de carga remota, donde Cargando no necesita nada, Exito lleva los datos y Error lleva el fallo. Contrasta ese diseño con el record de campos opcionales que lo imita mal, muestra por qué la carga heterogénea elimina de raíz las lecturas defensivas, e introduce los tipos recursivos y paramétricos como continuación natural. Cierra explicando que un constructor con datos correlaciona etiqueta e información de modo que el dato solo es accesible en el contexto donde tiene sentido, que es la razón última por la que Maybe derrota a null.

⏱ 18 min

La lección anterior dejó los tipos suma a medio presentar. Tal como los vimos —constructores desnudos, meras etiquetas que se distinguen entre sí— apenas superan a un enum bien hecho, y si eso fuera todo, la unión etiquetada sería una comodidad menor y no la herramienta central que promete ser. Lo que la transforma es una capacidad que parece pequeña y no lo es: cada constructor puede transportar sus propios datos, y la carga puede ser distinta en cada variante, en tipo y en número de argumentos. Cargando no necesita llevar nada, porque no hay nada que decir salvo que se está esperando; Exito necesita llevar los datos que llegaron; Error necesita llevar el fallo, que es información de una naturaleza completamente distinta. Un solo tipo, tres variantes, tres formas de carga incompatibles entre sí, y ninguna de ellas accesible fuera del caso al que pertenece. Esa correlación entre la etiqueta y su información es la que convierte un catálogo de estados en un modelo de datos honesto.

🎯 Al terminar esta lección sabrás
  • Escribir constructores que llevan datos y entender que la carga puede diferir entre variantes.
  • Leer un constructor con argumentos como una función de sus datos al tipo, y usarlo como valor de primera clase.
  • Modelar el estado de una carga remota con Inactivo, Cargando, Exito y Error en un único tipo.
  • Contrastar ese diseño con el record de campos opcionales que intenta imitarlo y siempre falla.

La carga: qué acompaña a cada etiqueta

Añadir datos a un constructor consiste en escribir, tras su nombre, los tipos de sus argumentos. No hay nombres de campo ni sintaxis adicional: la posición basta, porque la variante suele llevar una o dos cosas y no un formulario entero.

type Figura
    = Circulo Float
    | Rectangulo Float Float
    | Punto


areaAproximada : Figura -> Float
areaAproximada figura =
    case figura of
        Circulo radio ->
            3.1416 * radio * radio

        Rectangulo ancho alto ->
            ancho * alto

        Punto ->
            0

Fíjate en lo que acaba de ocurrir. Circulo lleva un Float, Rectangulo lleva dos y Punto no lleva ninguno; las tres son Figura con la misma legitimidad. No hay un campo radio que sobre en el rectángulo ni un campo alto que sobre en el círculo, porque la información no vive en el tipo sino en la variante. Un rectángulo no tiene radio, ni siquiera un radio nulo o vacío: sencillamente no existe tal cosa en ese caso, y el tipo lo dice sin rodeos.

💡
Un constructor con argumentos es literalmente una función

Esto no es una analogía didáctica sino el hecho exacto. Si preguntas al compilador por el tipo de Circulo, la respuesta es Float -> Figura; el de Rectangulo es Float -> Float -> Figura. Son funciones normales, currificadas como cualquier otra, y por tanto valores de primera clase que puedes pasar donde se espere una función. List.map Circulo radios construye una lista de figuras sin escribir una lambda; Json.Decode.map Exito decodificadorUsuarios convierte un decodificador de datos en un decodificador de estado. Un constructor sin argumentos, en cambio, no es una función sino directamente un valor. Esta dualidad hace que los tipos suma se integren en el estilo funcional sin ninguna costura.

El estado de carga: el ejemplo canónico

Ningún ejemplo enseña mejor la idea que el estado de una petición remota, porque es el sitio donde casi todo el software del mundo se ha modelado mal alguna vez. La situación tiene cuatro momentos posibles y su información asociada difiere radicalmente en cada uno.

type EstadoCarga
    = Inactivo
    | Cargando
    | Exito (List Usuario)
    | Error Http.Error


type alias Model =
    { usuarios : EstadoCarga }

Los paréntesis en Exito (List Usuario) son necesarios porque List Usuario es un tipo compuesto de dos palabras y sin ellos el compilador leería dos argumentos separados. Leído en voz alta, el tipo dice: aún no se ha pedido nada, o se está esperando, o llegaron estos usuarios, o falló con este error. Y dice también, con la misma fuerza, todo lo que no puede pasar: no hay usuarios mientras se carga, no hay error cuando hay éxito, no existe el limbo de haber terminado sin datos y sin fallo.

Compáralo con el diseño que aparece cuando no se dispone de sumas o no se piensa en ellas, que consiste en aplanar todo en un record y coordinar los campos a mano.

-- Antipatron: los campos no estan correlacionados y hay que rezar
type alias ModelPlano =
    { cargando : Bool
    , usuarios : List Usuario
    , error : Maybe String
    }


-- Legal, absurdo, y el compilador no dira nada
imposible : ModelPlano
imposible =
    { cargando = True, usuarios = [ usuarioX ], error = Just "fallo" }

Ese record admite cargando y con datos y con error a la vez. Admite también no cargando, sin datos y sin error, un estado que no distingue el aún no he pedido nada del he pedido y no había resultados. La vista que lo consuma tendrá que decidir un orden de comprobaciones —¿miro primero el error o la bandera?— y ese orden es una regla no escrita que vive en la cabeza de quien lo escribió y en ningún tipo. Con EstadoCarga, la vista no comprueba nada: pregunta por el caso, y en cada rama recibe exactamente los datos que ese caso garantiza.

flowchart LR
E[EstadoCarga] --> I[Inactivo sin datos]
E --> C[Cargando sin datos]
E --> S[Exito con List Usuario]
E --> F[Error con Http Error]
S --> V[La lista solo existe dentro de esta rama]
F --> W[El error solo existe dentro de esta rama]
style E fill:#f9e2af,color:#11111b
style V fill:#a6e3a1,color:#11111b
style W fill:#f38ba8,color:#11111b
📥

Carga heterogénea

Cada variante declara sus propios argumentos. Ninguna paga el precio de los datos que necesitan las otras.

🔧

Constructor como función

Exito : List Usuario -> EstadoCarga se pasa a List.map, a Json.Decode.map o a Task.map sin envolturas.

🔁

Recursión

Un constructor puede referirse al propio tipo, y así nacen árboles y listas: Nodo a (Arbol a) (Arbol a).

🎁

Parametrización

Un tipo suma admite variables de tipo, y de ahí salen Maybe a, Result e a y tu propio RemoteData e a.

Recursión y parámetros: el mismo mecanismo, más lejos

Dos extensiones naturales aparecen sin sintaxis nueva. La primera es que un constructor puede llevar como dato un valor del tipo que se está definiendo, y eso es todo lo que hace falta para describir estructuras arbitrariamente profundas. La segunda es que el tipo puede tomar parámetros, con lo que la forma se separa del contenido y se vuelve reutilizable.

type Arbol a
    = Vacio
    | Nodo a (Arbol a) (Arbol a)


-- Los tipos mas usados de Elm no son magia del lenguaje:
-- son tipos suma normales que cualquiera podria haber escrito
type Maybe a
    = Nothing
    | Just a


type Result error valor
    = Err error
    | Ok valor

Merece detenerse un segundo en la última observación, porque reordena el mapa mental de quien viene de otros lenguajes. Maybe y Result no son construcciones privilegiadas del compilador ni palabras clave con trato especial: son declaraciones type corrientes, escritas en Elm, que usan exactamente el mecanismo que acabas de aprender. La ausencia de valor no es un agujero en el sistema de tipos sino un constructor sin datos llamado Nothing; la presencia es un constructor con datos llamado Just. Si mañana necesitas un tipo con cuatro estados de carga en lugar de dos resultados, lo escribes tú mismo con las mismas herramientas, y quedará tan integrado en el lenguaje como los que vienen de fábrica. Eso es lo que significa que la unión etiquetada sea la herramienta de modelado: no describe algunos casos previstos, describe la forma de los datos en general.

⚠️
Un constructor con datos no es un record, y a veces querrás un record

La carga posicional es breve y perfecta para una o dos piezas de información, pero se vuelve ilegible enseguida. Un Usuario String String Int Bool obliga a recordar el orden y no perdona una permutación entre dos argumentos del mismo tipo. Cuando una variante necesita llevar muchos datos, lo idiomático es que lleve uno solo que sea un record: Exito DatosPagina en lugar de cinco argumentos sueltos. Suma y producto no compiten; se anidan. El diseño maduro alterna ambos ejes según lo que el dominio pida en cada nivel.

La carga correlacionada es la razón última por la que Maybe derrota a null

Lo que un constructor con datos hace, en el fondo, es algo que suena modesto y es extraordinariamente potente: correlaciona una etiqueta con una información de modo que ninguna de las dos puede existir sin la otra ni ser observada por separado. En un record plano, la bandera y los datos son vecinos independientes; nada en el tipo impide que la bandera diga cargando mientras los datos dicen otra cosa, y por eso la coherencia entre ambos se convierte en una obligación humana, sostenida por disciplina y por comentarios. En un tipo suma, la información viaja dentro de la variante, y eso tiene una consecuencia que solo se aprecia del todo cuando llega el case: no existe ninguna forma de leer la lista de usuarios sin estar dentro de la rama Exito, porque fuera de esa rama la lista literalmente no existe. La comprobación no es que hayas recordado hacerla; es que el acceso al dato pasa obligatoriamente por la comprobación, y no hay puerta trasera. Aquí está, formulado con precisión, por qué Maybe no es simplemente un null con mejores modales, que es la lectura superficial más común. Con null, el valor y su ausencia comparten el mismo tipo y por tanto el mismo camino de acceso: puedes escribir la desreferencia sin comprobar nada, el compilador te deja, y el fallo se descubre en producción a las tres de la mañana. Ese fue el error que Tony Hoare llamó su error de mil millones de dólares, y lo llamó así precisamente porque no era un fallo de implementación sino de tipos: haber permitido que un valor y su ausencia sean indistinguibles para el sistema que debía distinguirlos. Con Maybe, la ausencia tiene un constructor propio, y el dato del caso presente vive dentro de Just; para llegar hasta él hay que abrir el Just, y abrirlo obliga a decir qué ocurre cuando no hay nada. La comprobación deja de ser una precaución que puedes olvidar y pasa a ser el único camino que existe. Generaliza eso más allá de Maybe y tendrás el principio completo de esta lección: cada vez que en un diseño tuyo un dato solo tenga sentido bajo cierta condición, la forma correcta de expresarlo no es guardar el dato al lado de la condición y confiar, sino guardarlo dentro de la variante que representa esa condición. El dato deja entonces de ser opcional en el sentido débil de a veces está y a veces no, y pasa a ser inaccesible salvo en el contexto donde es verdadero. Esa es la diferencia entre un modelo que documenta sus invariantes y un modelo que los impone, y es la técnica sobre la que se levanta entera la cuarta lección de este nivel.

⚔️ Pon los datos dentro de la variante que los justifica
  1. Declara type Figura con Circulo, Rectangulo y Triangulo, dando a cada uno los argumentos que necesita y ninguno más, y escribe su función de área.
  2. Pregunta al compilador por el tipo de un constructor con dos argumentos y explica por qué la respuesta es una función currificada.
  3. Usa un constructor directamente como argumento de List.map para construir una lista de valores del tipo, sin escribir ninguna lambda.
  4. Modela EstadoCarga con sus cuatro variantes y escribe la vista que lo consume; comprueba que en ninguna rama necesitas una comprobación defensiva.
  5. Escribe el record plano equivalente con bandera, lista y Maybe de error, enumera al menos tres combinaciones sin sentido que admite y señala quién las impide hoy.
  6. Define type Arbol a recursivo y escribe una función que cuente sus nodos; observa que la recursión de la función copia la recursión del tipo.
  7. Escribe tú mismo Maybe y Result con type y argumenta por qué no necesitan ningún apoyo especial del compilador.