wandres.dev
RECORDS · datos inmutables

Records: definirlos, acceder a sus campos y el accesor que viaja solo

El record es la estructura con la que Elm representa cualquier dato compuesto por partes con nombre, y esta lección lo estudia desde su declaración hasta sus consecuencias sobre el sistema de tipos. Se examina la sintaxis literal y la convención de comas por delante, la diferencia entre el tipo anónimo y el nombre que le da un type alias, y el hecho de que el tipo de un record se determina por su forma y no por el nombre con el que lo hayas bautizado, con lo que dos records de campos idénticos son el mismo tipo aunque provengan de módulos distintos. Después se desarrolla el mecanismo de acceso: el punto que parece un método y no lo es, la existencia de un accesor autónomo que es una función de pleno derecho, su firma polimórfica mediante variable de fila y el uso natural de ese accesor como argumento en las funciones de orden superior. La lección cierra con la lectura conceptual del tipado estructural: por qué un record describe una capacidad y no una identidad, y por qué esa elección obliga a envolver en custom types todo aquello que el programa necesite distinguir de verdad.

⏱ 16 min

Todo lenguaje necesita una manera de decir que un dato tiene partes y que esas partes tienen nombre. La familia de la orientación a objetos resolvió el problema con la clase, que junta en un mismo recinto los datos y las operaciones que los tocan; la familia de los lenguajes dinámicos lo resolvió con el diccionario, que admite cualquier clave en cualquier momento y no promete nada. Elm elige un camino más antiguo y más austero: el record, una colección de campos con nombre y tipo, fijada en el instante de su creación, sin comportamiento adjunto, sin herencia, sin campos privados y sin manera alguna de cambiar lo que contiene. Un record no es un objeto pequeño ni un diccionario tipado. Es un producto cartesiano con etiquetas, la contraparte exacta de lo que en el nivel anterior era la unión etiquetada: si el custom type modela una elección entre alternativas, el record modela una conjunción de datos que se dan a la vez. Este nivel entero trata del segundo caso, y esta primera lección se ocupa del cimiento: cómo se declara un record, cómo se leen sus campos y por qué el punto que se usa para leerlos esconde una función suelta que puede pasarse de mano en mano.

🎯 Al terminar esta lección sabrás
  • Declarar records con la sintaxis literal y nombrarlos con un type alias, distinguiendo el tipo anónimo del nombre que lo abrevia.
  • Explicar el tipado estructural de los records y las consecuencias de que dos formas idénticas sean el mismo tipo.
  • Usar el accesor de campo como función autónoma y anotar su firma polimórfica con una variable de fila.
  • Decidir cuándo un record basta y cuándo el dominio exige envolverlo en un custom type que cree identidad.

Declarar: campos con nombre, tipos fijos y ninguna sorpresa

Un record se escribe entre llaves, con pares de campo e igual y valor, separados por comas. La convención de la comunidad coloca las comas al principio de cada línea, y aunque parezca una excentricidad tipográfica tiene una razón práctica: añadir o quitar un campo toca una sola línea y los diffs quedan limpios. El tipo de un record se escribe igual, sustituyendo el valor por su tipo.

-- Un record literal, con la convencion de comas por delante
ada =
    { nombre = "Ada"
    , edad = 36
    , activo = True
    }

-- Su tipo, escrito de forma anonima
firma : { nombre : String, edad : Int, activo : Bool } -> String
firma u =
    u.nombre

-- Y el mismo tipo, abreviado con un alias
type alias Usuario =
    { nombre : String
    , edad : Int
    , activo : Bool
    }

Lo primero que conviene fijar es que el record existe sin necesidad de alias: el tipo anónimo es un tipo perfectamente legítimo y el compilador lo maneja igual de bien. El alias no crea nada, solo evita escribir la forma completa cada vez. Lo segundo es que un record es cerrado en el sentido más fuerte: sus campos quedan determinados en el momento de construirlo y no existe operación alguna que añada uno nuevo, elimine uno existente o cambie el tipo de ninguno. No hay campos opcionales, no hay claves dinámicas y no hay manera de preguntar en tiempo de ejecución qué campos tiene un valor.

ℹ️
Un record no es un diccionario, aunque se le parezca

La confusión más común al venir de un lenguaje dinámico es tratar el record como si fuera un mapa de claves a valores. No lo es, y la diferencia es de naturaleza. Las claves de un record forman parte de su tipo, no de su contenido: se conocen en tiempo de compilación, no pueden calcularse y no pueden recorrerse. Si lo que necesitas es un conjunto de claves que crece o mengua según los datos, el record es la herramienta equivocada y lo que buscas es un Dict, que veremos en la última lección de este nivel. La regla es sencilla: campos conocidos al escribir el programa, record; claves conocidas al ejecutarlo, Dict.

El punto que no es un método y el accesor que viaja solo

Leer un campo se escribe con un punto, y esa notación es la única del lenguaje que parece importada de la orientación a objetos. No lo es. Aquí el punto no invoca nada ni resuelve nada en tiempo de ejecución: es una operación de proyección que el compilador conoce por completo. La prueba está en que Elm define además el accesor suelto, es decir, un punto seguido del nombre del campo que constituye por sí mismo una función.

-- Las dos formas son equivalentes
ada.nombre          -- "Ada"
.nombre ada         -- "Ada"

-- El accesor suelto es una funcion y por tanto se pasa como argumento
nombres : List Usuario -> List String
nombres =
    List.map .nombre

-- Su firma es polimorfica gracias a la variable de fila
-- .nombre : { a | nombre : b } -> b

mayoresDeEdad : List Usuario -> List Usuario
mayoresDeEdad =
    List.filter (\u -> u.edad >= 18)

-- Y se compone como cualquier otra funcion
inicial : Usuario -> String
inicial =
    .nombre >> String.left 1

La firma del accesor merece una lectura pausada. Dice que acepta cualquier record que tenga al menos un campo llamado nombre, de un tipo cualquiera, y devuelve exactamente ese tipo. La letra que precede a la barra representa el resto de campos, sean los que sean, y por eso el mismo accesor sirve para un Usuario, para un Producto y para cualquier forma futura que tenga un campo con ese nombre. Es polimorfismo estructural puro, resuelto en compilación y sin coste en tiempo de ejecución.

flowchart LR
R[Record con campos] --> P[Punto de acceso]
R --> A[Accesor suelto como funcion]
P --> V[Valor del campo]
A --> M[Argumento de List map]
M --> L[Lista de valores]
style A fill:#cba6f7,color:#11111b
style V fill:#a6e3a1,color:#11111b
style L fill:#89b4fa,color:#11111b
🧱

Producto con etiquetas

El record junta datos que se dan a la vez. Es la contraparte del custom type, que modela datos que se dan por turnos.

🔎

Accesor autónomo

El punto seguido de un nombre es una función de pleno derecho: se pasa, se compone y se aplica parcialmente.

📐

Tipado estructural

El tipo de un record es su forma. Dos records con los mismos campos son el mismo tipo aunque los llames distinto.

🔒

Cerrado y fijo

No se añaden campos, no se quitan y no se consultan en tiempo de ejecución. Todo está decidido al compilar.

Estructural, no nominal: la forma es la identidad

La propiedad más consecuente del sistema de records de Elm es que su tipado es estructural. Un record no pertenece a una clase ni recuerda de dónde salió: es su forma y nada más. Dos alias distintos que describan los mismos campos con los mismos tipos nombran, literalmente, el mismo tipo, y el compilador permite intercambiarlos sin conversión ni advertencia alguna.

-- Dos nombres, un unico tipo: son intercambiables
type alias Punto2D = { x : Float, y : Float }
type alias Vector2D = { x : Float, y : Float }

sumar : Punto2D -> Vector2D -> Punto2D
sumar p v =
    { x = p.x + v.x, y = p.y + v.y }

-- Para que el compilador los distinga hace falta un custom type
type Metros
    = Metros Float

type Segundos
    = Segundos Float

La consecuencia práctica es que el record es excelente para agrupar y pésimo para distinguir. Agrupar es su trabajo: reúne partes relacionadas bajo un nombre y las hace viajar juntas. Distinguir requiere otra herramienta, porque ninguna cantidad de alias creará una frontera que el compilador esté dispuesto a defender.

Hay además una asimetría que conviene fijar desde ahora porque atraviesa el nivel entero. El record es un tipo producto: contiene un valor de cada uno de sus campos, todos a la vez, y el número de valores distintos que puede representar es el producto de las posibilidades de cada campo. El custom type que estudiaste en el nivel anterior es un tipo suma: contiene un valor de una de sus variantes, y sus posibilidades se suman en lugar de multiplicarse. Esa diferencia aritmética, que parece un juego, resultará ser la herramienta de diagnóstico más útil al diseñar el modelo de una aplicación, porque un producto demasiado ancho es exactamente lo que permite representar situaciones que el dominio prohíbe.

💡
Un campo que se llama igual en todas partes se vuelve una interfaz gratis

Como el accesor es polimórfico sobre la variable de fila, elegir bien los nombres de campo tiene un efecto arquitectónico inesperado. Si todos los datos que representan entidades identificables usan un campo llamado id, entonces una única función de utilidad escrita sobre la variable de fila sirve para todas ellas sin necesidad de interfaces, sin clases de tipos y sin duplicación. La coherencia en el vocabulario de campos es, en Elm, lo más parecido que existe a declarar un contrato compartido.

La forma como identidad: qué se gana y qué se paga con el tipado estructural

Conviene detenerse en la decisión de fondo, porque separa dos filosofías del tipado que rara vez se enuncian con claridad. Un sistema nominal sostiene que un tipo es un nombre declarado y que dos declaraciones distintas producen tipos distintos aunque su contenido coincida hasta el último bit; un sistema estructural sostiene que un tipo es una descripción de forma y que la identidad de un valor se agota en la forma que tiene. Elm es deliberadamente mixto: sus custom types son nominales y sus records son estructurales, y esa combinación no es una inconsistencia sino un reparto de responsabilidades. El record se ocupa de la composición, es decir, de juntar partes de manera que el compilador pueda verificar que están todas y que cada una tiene el tipo debido; el custom type se ocupa de la distinción, es decir, de crear entidades que no se confunden con nada por más que se parezcan. Lo que se gana con la parte estructural es una ergonomía extraordinaria: no hay que declarar interfaces para que una función acepte varios tipos, no hay adaptadores entre módulos que describen la misma forma con nombres distintos, la variable de fila permite escribir funciones que exigen exactamente lo que usan, y un record fabricado sobre la marcha en el resultado de una función no obliga a inventarle una clase. Lo que se paga es que el compilador jamás protegerá una distinción que solo existe en tu cabeza. Si el dominio dice que un identificador de usuario y un identificador de pedido no deben mezclarse nunca, el sistema de records no lo sabrá, porque para él ambos son la misma cadena dentro de la misma forma. De ahí sale la única regla de diseño que hay que retener de esta lección y que gobernará todo el nivel: usa records para expresar qué partes tiene un dato, y custom types para expresar qué cosa es ese dato. Cuando alguien envuelve un Float en un tipo de una sola variante no está añadiendo ceremonia inútil, está comprando con una línea de código una garantía que el tipado estructural no puede darle, y esa compra suele ser la más rentable del programa entero.

⚔️ Desmonta el record hasta su forma
  1. Declara un record de cuatro campos sin alias, escribe su tipo anónimo completo en la firma de una función y comprueba que compila.
  2. Bautiza esa misma forma con dos alias de nombres distintos y demuestra que se pueden intercambiar libremente en cualquier posición.
  3. Anota a mano la firma del accesor .id con variable de fila y verifica que sirve para dos records sin ninguna relación entre sí.
  4. Reescribe una función con lambda para que use el accesor suelto y explica en qué casos la lambda sigue siendo imprescindible.
  5. Compón dos accesores para leer un campo y transformarlo, y razona por qué la composición no necesita conocer el resto del record.
  6. Toma dos alias que deban ser incompatibles según el dominio y conviértelos en custom types de una variante, midiendo qué código deja de compilar.