wandres.dev
RECORDS · datos inmutables

Records anidados: el dolor de la profundidad y cómo no sufrirlo

Elm no ofrece ninguna sintaxis para actualizar un campo que vive dentro de otro record, y esa ausencia es la queja más repetida por quien llega al lenguaje. Esta lección explica primero por qué duele: para cambiar un dato a tres niveles de profundidad hay que reconstruir a mano los tres records del camino, lo que produce expresiones anidadas ilegibles, propensas a error y que crecen cuadráticamente con la cantidad de campos profundos que la aplicación necesite tocar. Después examina si la ausencia es un olvido o una decisión, y sostiene que es lo segundo: la fricción sintáctica actúa como una señal de diseño que denuncia un modelo con más jerarquía de la necesaria. A partir de ahí se desarrollan las dos curas por orden de preferencia: aplanar el modelo, que casi siempre elimina el problema en lugar de gestionarlo, y cuando el anidamiento es genuinamente esencial, encapsularlo en funciones auxiliares que suben y bajan un solo nivel, componibles entre sí, con la forma canónica del actualizador que recibe una función en lugar de un valor.

⏱ 17 min

Hay un momento previsible en la vida de todo el que aprende Elm, y suele llegar la primera semana. El modelo ha crecido, alguien ha agrupado unos cuantos campos en un record interno porque parecía lo ordenado, y entonces hace falta cambiar un valor que está dos o tres niveles más abajo. La sintaxis de actualización, tan elegante para un campo directo, resulta no tener ninguna versión profunda: no existe forma de nombrar una ruta de campos, no existe operador que descienda por el árbol y no existe biblioteca oficial de lentes que lo resuelva. Lo que hay es la obligación de reconstruir a mano cada record del camino, de fuera hacia dentro, en una expresión que se lee peor cuanto más útil es. La reacción natural es interpretarlo como una carencia del lenguaje y buscar la manera de rellenarla. La lectura fértil es otra: la incomodidad no es un accidente, es un termómetro. Elm hace fácil lo que quiere que hagas y difícil lo que sospecha que no deberías estar haciendo, y un modelo profundamente anidado casi siempre resulta ser un modelo que nadie ha revisado desde que empezó a crecer.

🎯 Al terminar esta lección sabrás
  • Escribir a mano una actualización profunda y reconocer por qué su tamaño crece con cada nivel de anidamiento.
  • Interpretar la fricción sintáctica como una señal de diseño y no como una carencia que haya que compensar.
  • Aplanar un modelo anidado y evaluar en qué casos el aplanamiento es preferible a cualquier otra solución.
  • Encapsular el anidamiento esencial en funciones auxiliares componibles que suban y bajen un solo nivel.

El dolor, escrito entero y sin adornos

Empecemos por ver el problema en su forma cruda. Un modelo con tres niveles y la necesidad de cambiar un valor del fondo produce esta expresión, que no es un ejemplo exagerado sino exactamente lo que hay que escribir.

type alias Modelo =
    { usuario : Usuario, tema : Tema }

type alias Usuario =
    { nombre : String, ajustes : Ajustes }

type alias Ajustes =
    { idioma : String, notificaciones : Notificaciones }

type alias Notificaciones =
    { correo : Bool, movil : Bool }

-- Cambiar un booleano del fondo obliga a reconstruir todo el camino
activarMovil : Modelo -> Modelo
activarMovil modelo =
    let
        u =
            modelo.usuario

        a =
            u.ajustes

        n =
            a.notificaciones
    in
    { modelo
        | usuario =
            { u
                | ajustes =
                    { a | notificaciones = { n | movil = True } }
            }
    }

Trece líneas para cambiar un booleano. Y lo peor no es la longitud sino la escala: cada campo profundo que la aplicación necesite modificar exige su propia versión de esta escalera, de modo que el coste total crece con el producto del número de niveles por el número de campos tocados. Además, la parte central es puro ruido estructural, idéntica en todas las funciones que toquen esa rama, y por tanto un lugar excelente para que se cuele un error de copia que el compilador no detectará si los tipos coinciden.

⚠️
La incomodidad es intencionada, no un descuido

Existe la sintaxis para un nivel y no existe para varios, y esa asimetría es deliberada. Si Elm ofreciera una notación cómoda para descender por rutas arbitrarias, los modelos profundos serían indoloros y por tanto proliferarían, y con ellos llegaría la jerarquía rígida que el lenguaje lleva evitando desde su diseño. Al dejar la operación fea, el lenguaje convierte cada actualización profunda en una pregunta incómoda dirigida al programador: por qué está esto tan hondo. En la gran mayoría de los casos la respuesta honesta es que nadie lo decidió, simplemente creció así.

Aplanar: la cura que elimina el problema en vez de gestionarlo

La primera opción, y con diferencia la mejor cuando es viable, consiste en no tener el anidamiento. Muchos records internos existen por un impulso taxonómico y no por una necesidad real: agrupan campos que conceptualmente van juntos pero que la aplicación siempre trata por separado. Si nada consume el record interno como unidad, ese record no está aportando nada y sí está cobrando peaje en cada actualización.

-- Aplanado: los campos suben al modelo con nombres cualificados
type alias Modelo =
    { nombreUsuario : String
    , idioma : String
    , notificacionesCorreo : Bool
    , notificacionesMovil : Bool
    , tema : Tema
    }

-- La misma operacion de antes, ahora en una linea
activarMovil : Modelo -> Modelo
activarMovil modelo =
    { modelo | notificacionesMovil = True }

El criterio para decidir si un record interno merece existir es funcional, no estético: pregúntate si alguna función recibe ese record entero como argumento o lo devuelve como resultado. Si la respuesta es sí, el agrupamiento es real y tiene sentido; si nadie lo trata nunca como una unidad, es una carpeta vacía que solo sirve para alargar rutas. Conviene decirlo con claridad porque contradice un hábito muy arraigado: en Elm un modelo plano de quince campos suele ser mejor diseño que un modelo elegante de cuatro niveles.

flowchart TD
A[Actualizacion profunda] --> B{Se usa el record interno como unidad}
B -->|No| C[Aplanar el modelo]
B -->|Si| D[Funcion auxiliar por nivel]
C --> E[Una linea por cambio]
D --> F[Actualizadores componibles]
style C fill:#a6e3a1,color:#11111b
style D fill:#89b4fa,color:#11111b
style E fill:#a6e3a1,color:#11111b
🪜

Coste por nivel

Cada nivel obliga a reconstruir un record más. El ruido estructural crece y se repite en cada función que descienda.

🧭

Fricción como señal

Que duela no es un defecto del lenguaje. Es una pregunta sobre por qué el modelo tiene esa profundidad.

🥞

Aplanar primero

Si nadie trata el record interno como unidad, no es una unidad. Subir sus campos elimina el problema de raíz.

🧰

Un nivel cada vez

Cuando el anidamiento es real, cada nivel recibe una función auxiliar y las auxiliares se componen entre sí.

Cuando la profundidad es real: auxiliares de un solo nivel

A veces el anidamiento está justificado. Ocurre cuando el record interno tiene identidad propia, viaja solo entre funciones, se decodifica como unidad o representa el estado de una página que se compone dentro de otra. En esos casos la solución no es aplanar sino encapsular: escribir para cada nivel una función que reciba un transformador del interior y devuelva un transformador del exterior. La forma canónica recibe una función, no un valor, y eso es justamente lo que la hace componible.

-- Un actualizador por nivel: recibe una funcion y devuelve otra
enUsuario : (Usuario -> Usuario) -> Modelo -> Modelo
enUsuario f modelo =
    { modelo | usuario = f modelo.usuario }

enAjustes : (Ajustes -> Ajustes) -> Usuario -> Usuario
enAjustes f u =
    { u | ajustes = f u.ajustes }

enNotificaciones : (Notificaciones -> Notificaciones) -> Ajustes -> Ajustes
enNotificaciones f a =
    { a | notificaciones = f a.notificaciones }

-- La composicion recorre el camino, y se lee de fuera hacia dentro
activarMovil : Modelo -> Modelo
activarMovil =
    enUsuario (enAjustes (enNotificaciones (\n -> { n | movil = True })))

-- Cada nivel se reutiliza para cualquier otro cambio de esa rama
cambiarIdioma : String -> Modelo -> Modelo
cambiarIdioma nuevo =
    enUsuario (enAjustes (\a -> { a | idioma = nuevo }))

La diferencia con la escalera manual es que el ruido estructural se escribe una sola vez por nivel y se reutiliza para siempre. Cada auxiliar es minúscula, obviamente correcta y comprobable por separado, y las combinaciones nuevas no cuestan nada. Esta es, de hecho, la forma artesanal de lo que otros ecosistemas empaquetan como lentes; la diferencia es que aquí se escribe en tres líneas por nivel y sin introducir ninguna abstracción que haya que aprender aparte. Cuando cada nivel corresponde además a un módulo con su propio modelo y su propio mensaje, la auxiliar se convierte en el punto natural donde ese módulo se integra en el padre.

💡
Recibe una función, no un valor

La tentación al escribir una auxiliar es hacer que reciba el valor nuevo directamente. Funciona para un caso y no compone para ninguno más: cada campo del interior necesitaría su propia auxiliar. Al recibir una función de transformación del interior, una sola auxiliar por nivel cubre todos los cambios posibles de esa rama, presentes y futuros, y además permite que la transformación dependa del valor anterior, cosa imprescindible para incrementar contadores o alternar booleanos.

La sintaxis que falta es un juicio sobre la forma de tus datos

Vale la pena preguntarse por qué un lenguaje que ha cuidado tanto la ergonomía deja este agujero tan visible, porque la respuesta enseña más sobre diseño de software que sobre Elm. La actualización profunda es dolorosa en Elm por la misma razón por la que la mutación es imposible: no porque nadie haya tenido tiempo de arreglarlo, sino porque hacerlo cómodo abarataría una práctica que el lenguaje considera perjudicial. La jerarquía profunda en el estado es un préstamo con intereses que se pagan tarde. Al principio parece orden, porque agrupar cosas afines en cajas produce una sensación de limpieza inmediata; con el tiempo se descubre que cada caja es una ruta que hay que recorrer entera cada vez, que el sitio donde vive un dato deja de ser una consecuencia de su significado y pasa a ser una consecuencia de dónde se puso el primer día, y que mover un campo de rama obliga a tocar todo el código que lo alcanzaba. La jerarquía, en resumen, acopla la lógica a la topografía. Un modelo plano no tiene topografía que aprender: cualquier campo está a un paso de cualquier función, y reorganizar significa renombrar. Ahora bien, el argumento no es que el anidamiento sea siempre malo, porque no lo es. Cuando un subrecord se corresponde con una unidad genuina del dominio, se decodifica junto, se valida junto y se pasa junto a otras funciones, entonces esa estructura no es una carpeta sino un concepto, y el precio de recorrerlo está justificado por la cohesión que aporta. La prueba para distinguir un caso del otro es empírica y no admite discusión estética: mira si alguna firma de tu programa menciona ese record interno. Si ninguna lo hace, no existe como concepto, solo como ruta. Y cuando la profundidad sí está justificada, la respuesta correcta tampoco es sufrirla sino nombrarla: una función por nivel que reciba una transformación del interior convierte el ruido en vocabulario, y a partir de ahí el programa deja de hablar de reconstruir records y empieza a hablar de operar dentro de los ajustes del usuario, que es lo que en realidad quería decir desde el principio.

⚔️ Baja tres niveles sin escribir tres escaleras
  1. Construye un modelo de cuatro niveles y escribe a mano la actualización de un campo del fondo, contando las líneas.
  2. Repite la operación para un segundo campo de la misma rama y señala cuánto código es idéntico entre ambas versiones.
  3. Comprueba si algún tipo de tu programa menciona los records intermedios y decide con ese dato si merecen existir.
  4. Aplana el modelo y reescribe ambas actualizaciones, comparando el resultado con las versiones anteriores.
  5. En una rama que sí merezca ser profunda, escribe una auxiliar por nivel que reciba una función y compón el camino completo.
  6. Demuestra por qué una auxiliar que recibe un valor en vez de una función no sirve para alternar un booleano del interior.