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

El modelo mental completo: tipos, pureza, arquitectura y runtime en una sola imagen

Al final de un recorrido largo suele ocurrir algo curioso: se dominan las piezas por separado y sigue faltando la vista aérea que las une. Esta lección reconstruye Elm entero como una sola decisión tomada cuatro veces, en cuatro capas distintas y con cuatro vocabularios distintos. Los tipos, la pureza, la arquitectura y el runtime no son cuatro temas de un temario sino cuatro proyecciones del mismo principio: separar la descripción de un efecto de su ejecución, y concentrar toda la ejecución en un único lugar que el programador no escribe. Se recorre el circuito completo desde la pulsación del usuario hasta el repintado del navegador, nombrando en cada tramo quién es responsable y qué garantía sostiene ese tramo; se sitúan Cmd, Sub, Task y los ports sobre un mismo eje conceptual en lugar de tratarlos como mecanismos sueltos; y se extrae la consecuencia práctica más rentable de todo el track, que es la capacidad de ubicar cualquier fallo en la capa exacta donde puede vivir, porque a las demás capas se les ha prohibido alojarlo por construcción.

⏱ 22 min

Hay un momento, en cualquier aprendizaje técnico serio, en el que las piezas ya se manejan de una en una y sin embargo no se ven juntas. Se sabe escribir un decoder, se sabe encadenar un Task, se sabe partir un Msg por página, y aun así la pregunta de por qué todo eso encaja tan bien sigue teniendo una respuesta vaga. La respuesta no es vaga y cabe en una frase: Elm toma una sola decisión de diseño y la aplica cuatro veces seguidas, en cuatro capas que hablan idiomas diferentes pero dicen lo mismo. La decisión es separar la descripción de un efecto de su ejecución. Los tipos hacen que esa descripción sea verificable antes de ejecutar nada. La pureza garantiza que describir no sea ya ejecutar. La arquitectura fija la forma canónica de la descripción y el punto único donde el estado cambia. El runtime es el único que ejecuta, y no lo escribes tú. Nada de lo que has aprendido en trece niveles es una excepción a esto: Cmd es una descripción, Sub es una descripción, un Html msg es una descripción, un decoder es una descripción, un port es una descripción. Esta lección arma la imagen completa y la deja utilizable como instrumento de diagnóstico, no como resumen.

🎯 Al terminar esta lección sabrás
  • Reconstruir el circuito completo desde el evento del navegador hasta el repintado, nombrando quién ejecuta cada tramo y qué garantía lo sostiene.
  • Explicar por qué tipos, pureza, arquitectura y runtime son cuatro vistas de una única decisión y no cuatro asignaturas independientes.
  • Situar Cmd, Sub, Task, decoders y ports sobre el mismo eje de descripción frente a ejecución, y saber cuándo se cruza la frontera.
  • Diagnosticar un fallo cualquiera ubicándolo en la capa donde puede vivir, descartando por construcción las que no pueden alojarlo.

Una decisión, cuatro idiomas

Empieza por el idioma de los tipos. Un tipo en Elm no anota una variable: describe exhaustivamente el conjunto de valores posibles, y como no hay null, ni conversión implícita, ni escotilla dinámica, ese conjunto es exactamente el que se declaró. Maybe a es la ausencia hecha valor; Result e a es el fallo hecho valor; una unión etiquetada con cinco constructores es un dominio de cinco estados y ni uno más. Cuando en el nivel de estados imposibles aprendiste a sustituir tres booleanos por una unión de cuatro casos, no estabas ordenando código: estabas reduciendo el cardinal del tipo de ocho a cuatro y eliminando cuatro combinaciones que el programa nunca debió poder representar. El sistema de tipos es, en este marco, el idioma en el que se escribe qué puede existir.

El segundo idioma es la pureza. Una función pura es aquella cuyo valor de retorno depende solo de sus argumentos y cuya evaluación no altera nada fuera. En Elm no es una recomendación sino una propiedad del lenguaje: no existe sintaxis para lanzar una petición desde dentro de update. Lo que existe es sintaxis para construir un valor que describe esa petición. La consecuencia es que update puede reejecutarse mil veces con los mismos argumentos y devolver mil veces lo mismo, y que su tipo Msg -> Model -> ( Model, Cmd Msg ) es una descripción completa y honesta de lo que hace, sin nada oculto detrás.

-- Las tres funciones que definen una aplicacion entera.
-- Ninguna ejecuta nada: las tres construyen descripciones.

update : Msg -> Model -> ( Model, Cmd Msg )
view : Model -> Html Msg
subscriptions : Model -> Sub Msg

-- Y el runtime, que no escribes, cierra el bucle:
--   estado actual  ->  view      ->  pintado
--   evento         ->  Msg       ->  update
--   Cmd            ->  ejecucion ->  Msg de vuelta

El tercer idioma es la arquitectura. TEA no es un patrón de organización de carpetas: es la afirmación de que un programa interactivo es una máquina de estados con un único punto de transición. Todo lo que puede pasar está enumerado en Msg. Todo lo que se sabe está en Model. Todo cambio pasa por update y por ningún otro sitio. El cuarto idioma es el runtime, que es la contrapartida imprescindible: alguien tiene que ejecutar de verdad las peticiones, tocar el DOM y leer el reloj, y ese alguien es un intérprete escrito una sola vez, auditado una sola vez, y colocado fuera del alcance del código de aplicación.

Conviene notar que las cuatro capas no son independientes y que ninguna funciona sin las otras tres. La pureza sin tipos sería una convención inverificable, porque nada impediría que una función mintiera sobre lo que hace. Los tipos sin pureza describirían valores pero no comportamientos, que es exactamente la situación de un lenguaje tipado con efectos libres, donde una firma dice qué se devuelve pero calla lo que ocurre por el camino. La arquitectura sin runtime sería un patrón de disciplina que cada equipo aplicaría a su manera y erosionaría bajo presión. Y el runtime sin arquitectura no sabría qué ejecutar ni dónde devolver el resultado. La cohesión entre las cuatro es lo que produce la propiedad más citada del lenguaje, que no es la ausencia de excepciones sino algo más difícil de conseguir: que la firma de una función sea una descripción completa de esa función.

-- El mismo eje, expresado como tipos, en las cuatro puertas del programa.

Http.get : { url : String, expect : Expect msg } -> Cmd msg
Time.every : Float -> (Posix -> msg) -> Sub msg
Random.generate : (a -> msg) -> Generator a -> Cmd msg
port guardar : Value -> Cmd msg
port llegoAlgo : (Value -> msg) -> Sub msg

-- Observa el patron: todas devuelven Cmd o Sub, jamas el resultado.
-- Y todas reciben una funcion que nombra el mensaje de vuelta.
-- Ese es el contrato completo del ecosistema, sin excepciones.
🔱

Tipos

Definen qué valores pueden existir. Sin null ni any, el conjunto declarado es el conjunto real, y el compilador puede razonar sobre él con certeza total.

🧊

Pureza

Garantiza que construir la descripción de un efecto no lo dispare. Es lo que hace que update sea reproducible y que el bucle pueda rebobinarse.

🔁

Arquitectura

Fija la forma canónica: un estado, un vocabulario de mensajes, una transición. Convierte la aplicación en una máquina de estados legible entera.

⚙️

Runtime

Único ejecutor. Concentra en un solo lugar auditado todo lo que el resto del programa tiene prohibido hacer, y devuelve los resultados como mensajes.

El circuito, tramo por tramo

flowchart TD
A[Evento del navegador] --> B[Decoder de evento produce un Msg]
B --> C[Cola del runtime]
C --> D[update puro: Msg y Model]
D --> E[Model nuevo]
D --> F[Cmd: descripcion de efecto]
E --> G[view pura: Html Msg]
G --> H[Diff virtual y repintado]
E --> I[subscriptions: Sub Msg]
F --> J[Runtime ejecuta el efecto]
J --> K[Resultado envuelto en Msg]
I --> K
K --> C
style D fill:#a6e3a1,color:#11111b
style G fill:#89b4fa,color:#11111b
style J fill:#f38ba8,color:#11111b

Sigue el recorrido con atención a quién manda en cada tramo. El navegador emite un evento nativo. Ese evento no invoca una función tuya: atraviesa un decoder que intenta extraer de él un valor de tu tipo Msg, y si el decoder falla, el evento simplemente se descarta. Ya en el primer paso hay una frontera de tipos, y por eso onInput te entrega un String limpio en lugar de un objeto del navegador con veinte campos que no controlas. El Msg resultante entra en una cola gestionada por el runtime, que la procesa de uno en uno. No hay concurrencia observable dentro de update, y esa serialización es la razón de que no existan condiciones de carrera sobre el estado.

El runtime invoca entonces update con el mensaje y el modelo vigente. La llamada es pura: entra y sale sin tocar nada. Devuelve un modelo nuevo, que el runtime guarda, y un Cmd Msg, que el runtime interpreta. Aquí está el corazón del asunto, y conviene decirlo con precisión: el Cmd que devolviste no es una petición en vuelo. Es un valor inerte que dice qué habría que hacer y cómo llamar al mensaje que traerá el resultado. Que ese valor se convierta en tráfico de red es decisión y responsabilidad del runtime, y ocurre después de que update haya terminado.

-- Un efecto es un valor con el nombre del mensaje de vuelta dentro.
Guardar borrador ->
    ( { modelo | estado = Guardando }
    , Http.post
        { url = "/api/borradores"
        , body = Http.jsonBody (codificarBorrador modelo.borrador)
        , expect = Http.expectJson GuardadoTerminado decodificarRecibo
        }
    )

-- GuardadoTerminado : Result Http.Error Recibo -> Msg
-- El fallo tambien vuelve como dato, por la misma puerta que el exito.

Con el modelo nuevo en la mano, el runtime recalcula view y subscriptions. La vista devuelve una descripción de la interfaz, no la interfaz; el runtime la compara con la anterior y aplica al DOM real el mínimo conjunto de operaciones necesarias. Las suscripciones declaran, en función del estado actual, qué fuentes externas deben estar escuchando ahora mismo: si el modelo dice que la animación terminó, el Sub deja de pedir fotogramas y el runtime cancela la escucha sin que tú desuscribas nada. La simetría es total: Cmd describe una cosa que debe ocurrir una vez, Sub describe una cosa que debe estar ocurriendo mientras el estado sea el que es, y ambas se resuelven en mensajes que vuelven a la misma cola por la que entró la pulsación original.

💡
Un solo eje explica todo el ecosistema

Coloca en una recta todo lo que has visto y comprobarás que solo hay dos posiciones. A la izquierda, descripciones inertes: Html msg, Cmd msg, Sub msg, Task x a, un decoder, un valor de port saliente, un Attribute msg. A la derecha, un único ejecutor: el runtime. No hay nada en medio y no hay una tercera posición. Cuando una biblioteca de Elm te parezca extraña, pregunta a qué lado de la recta vive lo que devuelve; casi siempre la respuesta disuelve la extrañeza. Y cuando escribas Elm y sientas la tentación de hacer que algo ocurra ahora, la respuesta idiomática es siempre la misma: no hagas que ocurra, describe que debe ocurrir y nombra el mensaje con el que quieres enterarte.

Lo que cada capa vuelve imposible

Un modelo mental sirve de poco si solo explica lo que el sistema hace; su valor está en decir con exactitud qué no puede pasar. La pureza de update elimina la clase entera de fallos en los que el estado cambia desde un sitio que nadie recuerda, porque no existe otro sitio. La exhaustividad de case of elimina los fallos por caso nuevo no contemplado, porque añadir un constructor rompe la compilación de cada punto que debía atenderlo. La ausencia de null elimina el acceso a lo inexistente. El decoder en la frontera elimina la clase de fallos en que el servidor cambió un campo y la interfaz se rompió tres pantallas más allá, porque el desajuste se detecta en el borde y se convierte en un Result que estás obligado a tratar. El port elimina la posibilidad de que JavaScript lance una excepción dentro de tu programa, porque no hay llamada directa: hay un mensaje que cruza y otro que vuelve.

Hay además una familia de imposibilidades menos comentada y que en aplicaciones grandes pesa tanto como las anteriores: las que derivan de la serialización del bucle. Como el runtime procesa la cola de mensajes de uno en uno y update es la única transición, no puede ocurrir que dos respuestas del servidor que llegan casi a la vez se pisen a mitad de una modificación del estado. Cada una se atiende entera antes de que empiece la siguiente, y por tanto el estado que ve la segunda es el que dejó la primera. En un modelo con manejadores concurrentes que mutan una estructura compartida, esa garantía hay que construirla a mano y casi nadie la construye. Aquí es una consecuencia gratuita de la forma del bucle.

-- Lo que el compilador impide, expresado como codigo que no existe.
--
--   update no tiene sintaxis para ejecutar una peticion.
--   case of no compila si falta una rama del tipo.
--   no hay ningun valor nulo que colocar en un campo.
--   no hay llamada directa a JavaScript, solo mensajes que cruzan.

-- Lo unico que si compila: devolver la descripcion y el mensaje de vuelta.
Reintentar ->
    ( { modelo | informe = Cargando }, pedirInforme modelo.id )
⚠️
Lo que sigue siendo tu responsabilidad

Ninguna de esas garantías es una garantía de corrección. El compilador comprueba que tu programa es coherente consigo mismo, no que sea correcto respecto a lo que el negocio necesita. Un Model que representa fielmente un dominio mal entendido compilará sin una queja. Un update que suma cuando debía restar es puro, exhaustivo y erróneo. Un decoder que acepta un campo con el nombre equivocado es un decoder impecable que produce datos falsos. La frase honesta no es que Elm elimina los errores, sino que Elm elimina los errores de una familia entera y concentra toda tu atención restante en la familia que de verdad requiere pensar: la de modelar bien.

Diagnosticar con el mapa en la mano

La utilidad inmediata del mapa es reducir el espacio de búsqueda cuando algo falla. Si la pantalla muestra un dato viejo, el fallo no puede estar en el repintado, porque el runtime repinta siempre que el modelo cambia; está en que el modelo no cambió, y por tanto en update, o en que el mensaje no llegó, y por tanto en el decoder del evento. Si un dato llega del servidor y no aparece, mira si el Cmd se devolvió realmente en la rama que creías, porque devolver Cmd.none por descuido en una rama es el fallo más común y el más silencioso. Si algo ocurre dos veces, sospecha de una suscripción que el modelo mantiene activa en un estado en el que ya no debería. Si el navegador se queda sin responder, el problema está en el ejecutor y casi siempre en un puerto o en una vista que reconstruye una lista enorme sin Html.Keyed. Cada síntoma tiene un conjunto pequeño de causas posibles porque la arquitectura prohíbe el resto.

-- Protocolo de diagnostico en cuatro preguntas, en este orden.
--
-- 1. Llego el mensaje?
--    Un Debug.log en la primera linea de update lo responde.
--    Si no llego, el fallo esta en el decoder del evento o en la vista.
--
-- 2. Cambio el modelo como esperabas?
--    Es una funcion pura: llamala desde una prueba con el mismo Msg.
--    Si el resultado es el esperado, el fallo no esta en update.
--
-- 3. Devolviste el Cmd que creias devolver?
--    Revisa cada rama. Cmd.none silencioso es el sospechoso habitual.
--
-- 4. Que dice subscriptions con este modelo?
--    Si algo ocurre de mas o de menos, casi siempre esta aqui.

La cuarta pregunta merece un comentario aparte porque es la que menos gente se hace. Las suscripciones se recalculan en cada vuelta del bucle a partir del modelo, y eso significa que su corrección depende de que el modelo represente con fidelidad la situación. Un modelo que conserva un campo obsoleto porque nadie lo limpió mantiene viva una escucha que ya no corresponde, y el síntoma aparece lejos, en forma de mensajes inesperados en un estado en el que no deberían existir. La causa raíz, sin embargo, casi nunca está en subscriptions, que es una función breve y honesta: está en el modelado, y es el mismo problema de estados representables que no corresponden a nada real. Diagnosticar con el mapa termina, muchas veces, devolviéndote al primer nivel de todos.

La misma idea aparece en todas partes donde alguien logró domesticar un sistema grande, y por eso este modelo mental sobrevive al lenguaje

Merece la pena decir en voz alta por qué esta imagen vale mucho más que Elm. Lo que has estado estudiando trece niveles es una instancia particularmente pura de un principio que reaparece cada vez que un colectivo de ingenieros consigue hacer manejable algo que se le iba de las manos. Es lo que hace un compilador cuando construye un árbol de sintaxis en lugar de generar código mientras lee: primero una representación inspeccionable, después la ejecución. Es lo que hace una base de datos cuando escribe el registro de transacciones antes de tocar las páginas de datos: primero la descripción del cambio, después el cambio. Es lo que hace Kubernetes cuando compara un estado deseado declarado con el estado observado y emite acciones de reconciliación, exactamente igual que el runtime compara dos árboles virtuales y emite parches al DOM. Es lo que hizo el patrón de comando en la programación orientada a objetos, y lo que hace un intérprete de efectos algebraicos en un lenguaje de investigación, y lo que hace Terraform con su plan antes de su apply. En todos los casos el movimiento es idéntico: se interpone un valor entre la intención y el acto, y todo lo que en el sistema anterior era imposible de inspeccionar, de registrar, de reintentar, de encolar, de simular o de someter a prueba se vuelve trivial, porque ahora es un dato. El motivo por el que Elm resulta tan aleccionador es que aplica ese movimiento sin excepciones y sin puertas traseras, y que su compilador te impide, no te sugiere, romperlo. Cuando vuelvas a un lenguaje que sí tiene puertas traseras, no habrás perdido nada de esto. Habrás ganado el criterio para saber en qué parte de tu código el efecto está describiéndose y en cuál está disparándose, y ese criterio es, en la práctica, la diferencia entre un sistema que puedes razonar y uno que solo puedes observar.

⚔️ Reconstruye el mapa sin mirar y ponlo a trabajar
  1. Dibuja de memoria el circuito completo, marcando con dos colores qué tramos son puros y cuáles los ejecuta el runtime, y anota en cada frontera qué tipo la cruza.
  2. Coloca sobre el eje descripción frente a ejecución los siguientes elementos: Html msg, Cmd msg, Sub msg, Task x a, un decoder, un port saliente y un port entrante. Justifica cada posición.
  3. Toma una aplicación tuya y localiza el punto exacto en que un dato externo se convierte en un tipo del dominio. Si hay más de uno, argumenta si eso es una duplicación o una frontera legítima.
  4. Provoca deliberadamente tres fallos distintos: un Cmd.none en la rama equivocada, una suscripción que no se apaga y un decoder que espera un campo inexistente. Antes de arreglar cada uno, escribe qué capa podía alojarlo y cuáles quedaban descartadas.
  5. Explica a alguien que no conoce Elm por qué update puede devolver una petición sin haberla hecho. Si necesitas más de cinco frases, el modelo aún no está compacto.
  6. Argumenta en contra: describe un tipo de aplicación para el que esta arquitectura sea un mal negocio y sé concreto sobre qué coste concreto la hace mal negocio ahí.