wandres.dev
NIVEL DIOS: SÍNTESIS · pensar funcionalmente

Los errores de diseño más caros en Elm: el Model sin forma, el Msg genérico y los records anidados

Elm impide por construcción la mayoría de los fallos de ejecución, y precisamente por eso los problemas que quedan son de otra naturaleza: no son errores que rompan la aplicación sino decisiones de diseño que la vuelven progresivamente más cara de cambiar. Esta lección diagnostica los tres más costosos, que además son los tres más frecuentes en proyectos que empezaron bien. El primero es el Model que crece por acumulación hasta convertirse en un saco de campos donde ningún estado es explícito y todas las combinaciones son representables. El segundo es el Msg genérico, cuyos constructores nombran gestos de la interfaz en lugar de hechos del mundo y que arrastra un update incapaz de explicar qué hace la aplicación. El tercero son los records profundamente anidados, cuya actualización requiere ceremonia creciente y cuya sola presencia suele indicar que falta un submódulo. Para cada uno se ofrece el síntoma temprano, la métrica que lo objetiva, el coste real que produce y una ruta de salida incremental que no exige reescribir el proyecto.

⏱ 23 min

Cuando un lenguaje elimina los fallos de ejecución ocurre algo que nadie anticipa: los problemas no desaparecen, cambian de categoría. En un proyecto Elm maduro casi nunca hay incidentes de producción por un valor inexistente o por una rama no contemplada, y sin embargo puede haber una sensación persistente de que cada cambio cuesta más que el anterior, de que el fichero principal se ha vuelto ilegible y de que añadir una pantalla implica tocar cinco sitios que no tienen nada que ver con ella. Eso no es un fallo, es una deuda de diseño, y su rasgo característico es que el compilador nunca protesta, porque un mal diseño puede ser perfectamente coherente consigo mismo. Tres decisiones concentran la mayor parte de esa deuda, y las tres se toman temprano, sin mala intención y con muy buenos argumentos locales. Esta lección las nombra, las mide, cuantifica lo que cuestan y da la salida de cada una. No son errores de principiante: son errores de proyecto que crece, y por eso aparecen justo cuando el proyecto empieza a importar.

🎯 Al terminar esta lección sabrás
  • Reconocer el Model sin forma por sus síntomas tempranos y objetivar su coste contando estados representables frente a estados reales.
  • Distinguir un Msg que nombra hechos del mundo de uno que nombra gestos de la interfaz, y entender por qué la diferencia se paga en el update.
  • Diagnosticar el anidamiento profundo de records como señal de frontera mal puesta, no como problema de sintaxis.
  • Aplicar rutas de salida incrementales que mejoren el diseño sin bloquear la entrega ni exigir una reescritura.

El Model que crece sin forma

Todo empieza de forma razonable. El primer Model tiene cuatro campos y se entiende de un vistazo. Llega un requisito y hace falta saber si el panel está cargando, así que se añade un booleano. Llega otro y hace falta guardar el mensaje de error, así que se añade una cadena vacía cuando no hay error. Después un campo para el elemento seleccionado, opcional porque puede no haberlo. Ninguna de esas decisiones es criticable por separado, y tres meses más tarde el Model tiene veintiocho campos, siete de ellos booleanos, cinco opcionales y tres cadenas que a veces están vacías por significar ausencia. El síntoma temprano es fácil de detectar si se busca: la aparición de comentarios que explican relaciones entre campos, del tipo que advierten de que un campo solo es válido cuando otro vale cierto. Ese comentario es la confesión de que existe una regla del dominio que el tipo no expresa y que ahora vive en la memoria del equipo.

-- Sintoma: el tipo admite mucho mas de lo que el mundo permite.

type alias Model =
    { cargando : Bool
    , error : String        -- vacio significa que no hay error
    , datos : List Fila     -- vacio significa que no hay datos... o si?
    , seleccion : Maybe Fila
    , guardando : Bool
    , guardado : Bool
    , editando : Bool
    }

-- Combinaciones representables: 2*2*2*2 booleanos por los casos de
-- error, datos y seleccion. Del orden de cientos.
-- Combinaciones reales del dominio: cinco o seis.

La métrica que convierte la intuición en argumento es el conteo de estados. Multiplica el cardinal de cada campo y compara el resultado con el número de situaciones que el producto admite de verdad. Cuando la proporción se dispara, cada una de esas combinaciones sobrantes es un estado que el programa puede alcanzar y que nadie diseñó, y la única defensa disponible son condicionales dispersos por la vista y por el update. El coste no aparece como fallo sino como fricción: la vista se llena de comprobaciones encadenadas, cada nuevo requisito obliga a revisar si interfiere con las banderas existentes, y nadie se atreve a borrar un campo porque no está claro quién lo lee.

-- Salida: convertir las banderas en una union que nombre situaciones.

type EstadoPanel
    = Vacio
    | Cargando
    | Fallido ErrorApi
    | Mostrando (List Fila) (Maybe Fila)
    | Guardando (List Fila)


type alias Model =
    { panel : EstadoPanel
    , filtros : Filtros
    }
💡
La regla de las dos banderas

Hay una heurística que atrapa el problema antes de que crezca: en cuanto dos booleanos del mismo Model describan aspectos del mismo proceso, sustitúyelos por una unión. Dos banderas admiten cuatro combinaciones y casi siempre una de ellas es absurda, del tipo cargando y guardado a la vez. Tres banderas admiten ocho y ya nadie sabe cuáles son legítimas. La sustitución cuesta veinte minutos cuando hay dos banderas y cuesta dos días cuando hay siete, así que el momento de hacerla es exactamente el primero en que aparece la segunda.

El Msg genérico y el update que no explica nada

El segundo error es más sutil porque no produce combinaciones inválidas y por tanto no lo detecta el conteo de estados. Consiste en definir mensajes que nombran lo que el usuario tocó en lugar de lo que ocurrió. Constructores como ClicBoton, CambioInput o el temible Actualizar Campo String parecen económicos porque evitan escribir muchos casos, y su coste se paga entero en el update, que deja de ser una descripción legible del comportamiento y pasa a ser un despachador de gestos que debe reconstruir la intención a partir de un identificador de widget. La forma extrema del error es el mensaje único que transporta una función, con la que el update se convierte en un aplicador anónimo y toda la lógica se dispersa por la vista, donde no puede probarse ni leerse en conjunto.

-- Antipatron: el update no sabe que esta pasando.

type Msg
    = Clic String
    | Cambio String String
    | ConDatos (Result Http.Error Value)


-- Peor todavia: el update abdica de su papel.
type Msg
    = Aplicar (Model -> Model)


-- Idiomatico: cada constructor nombra un hecho del mundo.
type Msg
    = PidioExportar Formato
    | EscribioFiltroDeTexto String
    | SeleccionoFila IdFila
    | LlegoElInforme (Result ErrorApi Informe)
    | CaducoLaSesion

La prueba diagnóstica es rápida y no requiere leer código: enseña el tipo Msg a alguien que conozca el producto pero no el proyecto y pídele que explique qué hace la aplicación. Si puede, el vocabulario es bueno. Si necesita abrir la vista para entenderlo, el vocabulario nombra gestos. Y hay una consecuencia práctica que suele decidir la discusión: con mensajes que nombran hechos, las pruebas del update se leen como especificaciones del negocio y sobreviven a un rediseño completo de la interfaz; con mensajes que nombran gestos, cambiar un botón por un menú desplegable obliga a tocar el update, que no debería tener ninguna opinión sobre botones.

⚠️
El mensaje que transporta una función es una puerta trasera

Un Msg con un constructor que lleva dentro una función de modelo a modelo compila, funciona y parece ingenioso. Lo que hace en realidad es anular casi todo lo que sostiene la arquitectura: el update deja de ser el lugar donde se decide, el registro de mensajes deja de ser inspeccionable porque una función no se puede comparar ni mostrar, las herramientas de depuración por historial dejan de servir y las pruebas de transición se vuelven imposibles de escribir con sentido. Es el equivalente exacto de una escotilla dinámica en un lenguaje tipado: se abre una vez por comodidad y se queda para siempre.

Los records anidados como síntoma de frontera mal puesta

El tercer error se presenta como un problema de sintaxis y no lo es. Alguien agrupa campos relacionados dentro de un record, después agrupa dentro de él otro record, y al cabo de poco actualizar un valor a tres niveles exige tres expresiones de actualización encajadas que ocupan seis líneas para cambiar un entero. La reacción habitual es buscar una biblioteca de lentes que devuelva la comodidad perdida, y esa reacción trata el síntoma dejando intacta la causa. Elm no ofrece una sintaxis cómoda para el anidamiento profundo por la misma razón por la que no ofrece herencia: porque la incomodidad es informativa. Está indicando que ese subárbol tiene identidad propia y que quien debería saber actualizarlo es un módulo suyo, con sus propias funciones, no el update raíz metiendo la mano a tres niveles de distancia.

-- Sintoma: la ceremonia crece con la profundidad.

actualizarTema modelo color =
    let
        usuario =
            modelo.usuario

        preferencias =
            usuario.preferencias

        tema =
            preferencias.tema
    in
    { modelo
        | usuario =
            { usuario
                | preferencias =
                    { preferencias | tema = { tema | acento = color } }
            }
    }


-- Salida: el subarbol expone sus propias operaciones.
-- En modulo Preferencias:
--   conAcento : Color -> Preferencias -> Preferencias

actualizarTema modelo color =
    { modelo | usuario = Usuario.mapPreferencias (Preferencias.conAcento color) modelo.usuario }
flowchart TD
A[Sintoma en el codigo] --> B{Que indica}
B --> C[Muchas banderas booleanas]
B --> D[Msg que nombra gestos]
B --> E[Records de tres niveles]
C --> F[Faltan uniones etiquetadas]
D --> G[Falta vocabulario del dominio]
E --> H[Falta un submodulo con identidad]
F --> I[Refactor local por proceso]
G --> J[Renombrar y partir el Msg]
H --> K[Extraer modulo con sus operaciones]
style F fill:#f9e2af,color:#11111b
style G fill:#f9e2af,color:#11111b
style H fill:#f9e2af,color:#11111b

Salir sin reescribir

Ninguno de los tres exige una reescritura, y proponerla suele ser la forma más segura de que no se arregle nada. El Model sin forma se desmonta por procesos, no por campos: se elige un proceso concreto, se identifican las tres o cuatro banderas que lo describen, se define la unión que las sustituye y se migra solo ese proceso, dejando el resto del Model intacto. El compilador señala cada punto a tocar y el cambio se cierra en una sesión. El Msg genérico se corrige por renombrado progresivo: cada vez que se toca una rama por otro motivo, se le da el nombre del hecho que representa y se parte el constructor si transportaba varios significados. En unas semanas el vocabulario se ha vuelto legible sin que nadie haya abierto un ticket de refactorización. Los records anidados se resuelven extrayendo el nivel más profundo a un módulo con sus operaciones propias, empezando por el subárbol que más se actualiza, que es donde el retorno es inmediato.

Los tres errores son el mismo error visto desde tres sitios, y ese error es dejar que la representación se elija sola

Si los pones uno al lado del otro verás que no son tres problemas sino tres manifestaciones del mismo. En los tres casos alguien dejó de decidir cómo representar algo y permitió que la representación se acumulara por sedimentación, añadiendo una capa cada vez que llegó un requisito. El Model con veintiocho campos no se diseñó nunca: se depositó. El Msg que nombra gestos no eligió un vocabulario: copió el que ya usaba el navegador, que es el de los eventos del DOM, y ese vocabulario pertenece a otra capa y a otro problema. El record de cuatro niveles no modeló una jerarquía del dominio: reprodujo la jerarquía accidental de la pantalla o la del JSON que llegó del servidor. Y aquí está lo que conviene llevarse, porque es válido mucho más allá de Elm: en todo sistema que dura, la representación siempre se elige, y la única variable es si la eligió alguien conscientemente o si la eligió la inercia. Cuando la elige la inercia, el resultado tiene una firma reconocible: admite muchos más estados de los que existen, habla el idioma de la capa equivocada y su forma refleja la historia de los requisitos en lugar de la estructura del problema. Los tres síntomas que has visto son exactamente esa firma. Por eso la contramedida no es una técnica sino un hábito de revisión periódica con una sola pregunta: si hoy empezara este módulo de cero, sabiendo lo que sé ahora, elegiría estos tipos. La respuesta casi nunca es un sí rotundo, y la distancia entre la respuesta y el código actual es la medida más fiable de deuda de diseño que vas a encontrar, muy superior a cualquier métrica automática, porque las métricas cuentan líneas y complejidad ciclomática mientras que esta cuenta algo que importa de verdad: cuánto de tu código existe por razones que ya no son ciertas.

⚔️ Audita un proyecto real con los tres diagnósticos
  1. Cuenta los estados representables de tu Model multiplicando el cardinal de cada campo y estima cuántos corresponden a situaciones reales. Escribe la proporción y decide si te sorprende.
  2. Localiza el primer par de booleanos que describan el mismo proceso y sustitúyelos por una unión, migrando solo ese proceso. Mide cuántos condicionales desaparecen de la vista.
  3. Enseña tu tipo Msg a alguien que conozca el producto y no el código, y pídele que explique la aplicación. Anota cada constructor que tuvo que preguntar.
  4. Busca la actualización de record más profunda del proyecto y decide si el subárbol merece un módulo propio. Si lo merece, extráelo con dos operaciones y comprueba cuánta ceremonia desaparece del raíz.
  5. Busca cualquier mensaje que transporte una función o un identificador de widget y reescríbelo como hecho del dominio. Explica qué prueba se vuelve posible después.
  6. Argumenta en contra de esta lección: describe un contexto real donde un Model plano con banderas sea la decisión correcta y sé preciso sobre qué lo hace correcto ahí.