Por qué ports y no llamadas directas a JavaScript
Casi todo lenguaje que compila al navegador ofrece una vía rápida para llamar a JavaScript desde dentro: una función de escape, un bloque en línea, una directiva de interoperabilidad. Elm no la ofrece, y no por descuido ni por purismo decorativo, sino porque esa vía destruiría exactamente aquello que hace valiosas sus garantías. Esta lección desarrolla el argumento completo: qué significa que el compilador prometa la ausencia de excepciones en tiempo de ejecución, por qué una llamada directa convierte esa promesa global en una promesa local sin valor, y cómo el diseño de los ports transforma la interoperabilidad en un buzón asíncrono y unidireccional que preserva la pureza de update. Veremos por qué la frontera es un valor de tipo Cmd o Sub y nunca una función que devuelve el resultado, por qué el rodeo aparentemente incómodo es el precio exacto de la garantía, y qué clase de errores desaparecen del sistema cuando lo impuro queda confinado detrás de una interfaz de datos.
Existe una pregunta que todo programador se hace la primera vez que necesita usar una biblioteca de JavaScript desde Elm, y que suele formularse con cierta impaciencia: por qué no puedo simplemente llamarla. En casi cualquier otro lenguaje compilado al navegador la respuesta sería que sí puedes. TypeScript llama a JavaScript porque es JavaScript. ReScript y PureScript ofrecen bloques de código externo que se insertan literalmente en la salida. Kotlin y Scala tienen anotaciones para declarar funciones nativas. Elm es la excepción deliberada: no hay ninguna construcción del lenguaje que permita invocar una función de JavaScript y recibir su resultado ahí mismo. La única frontera disponible es un buzón asíncrono por el que se depositan y se recogen datos, nunca se ejecutan llamadas. Esta lección defiende que esa restricción no es una limitación que Elm arrastre, sino la condición sin la cual todas sus demás promesas serían retórica vacía.
- Explicar por qué una función de escape hacia JavaScript anularía la garantía global de ausencia de excepciones en tiempo de ejecución.
- Describir la frontera de los ports como un buzón asíncrono y unidireccional, en oposición a una llamada síncrona.
- Justificar por qué un port de salida produce un
Cmdy uno de entrada produce unaSub, y nunca un valor devuelto. - Identificar qué clase de errores desaparecen cuando lo impuro queda confinado detrás de una interfaz de datos.
Qué se rompería con una llamada directa
Cuando se dice que un programa Elm no lanza excepciones en tiempo de ejecución, la afirmación tiene un alcance que conviene medir con precisión: no dice que sea improbable, dice que el compilador ha demostrado que no puede ocurrir, para todo el código que ha visto. Esa demostración descansa en una cadena de suposiciones que se sostienen juntas o no se sostienen: toda función es total sobre su tipo de entrada, todo valor tiene el tipo que declara, ninguna expresión muta nada, ninguna función devuelve un valor distinto ante los mismos argumentos. Introduce un único punto donde el programa pueda invocar código arbitrario y recibir de vuelta un valor cualquiera, y las cuatro suposiciones caen a la vez para todo el programa, no solo para esa línea.
Conviene notar que esta cadena no se debilita gradualmente. Un sistema de tipos no es una red que atrapa más cuanto más tupida: es un argumento deductivo, y un argumento deductivo con una premisa falsa no demuestra un poco menos, no demuestra nada. Por eso la discusión sobre las puertas de escape no admite el término medio que suele proponerse, el de usarlas con moderación. Una puerta usada con moderación sigue siendo una puerta, y el compilador no puede saber si hoy estuvo cerrada.
Piensa en qué tendría que declarar esa hipotética función de escape. Si prometiera devolver un Int, el compilador confiaría en la promesa y propagaría esa confianza por todo el grafo de llamadas, pero nadie podría verificarla: el JavaScript del otro lado puede devolver una cadena, un objeto, undefined o lanzar una excepción. La firma sería una declaración de intenciones sin respaldo, exactamente el mismo contrato roto que hace frágil a TypeScript en sus fronteras. Y el daño no se detendría en el tipo: una llamada síncrona podría mutar el modelo por referencia, disparar efectos en mitad de una expresión pura y hacer que dos evaluaciones idénticas de update produjeran resultados distintos.
-- Esto NO existe en Elm, y la lección es por qué
-- foreign eval : String -> Int
-- Lo que sí existe: una declaración de buzón
port module Main exposing (main)
-- Salida: describe un envío, no lo ejecuta
port guardar : String -> Cmd msg
-- Entrada: describe una escucha, no la resuelve
port llegoAlgo : (String -> msg) -> Sub msg
La garantía es global
La ausencia de excepciones no se demuestra función por función: se demuestra para el programa entero. Una sola grieta la degrada a promesa local sin valor.
Buzón, no teléfono
Depositas un dato y sigues. No esperas respuesta en la misma expresión. Esa asincronía obligatoria es lo que impide que lo impuro contamine update.
Dos direcciones separadas
Salida y entrada son declaraciones distintas, con tipos distintos. No hay ida y vuelta atómica porque una ida y vuelta atómica sería una llamada.
Solo pasan datos
Por el buzón viajan valores serializables, nunca funciones ni referencias vivas. Lo que cruza es información, no capacidad de ejecución.
El buzón: asíncrono por diseño, no por comodidad
La forma de la frontera no es arbitraria. Un port de salida tiene el tipo de una función que recibe un dato y produce un Cmd, es decir, produce una descripción de efecto que update devuelve al runtime junto con el modelo nuevo. Nada se ejecuta en el momento de construir ese valor: update sigue siendo una función pura que recibe un mensaje y un modelo y devuelve un modelo y una descripción. Un port de entrada tiene el tipo de una función que recibe un constructor de mensaje y produce una Sub, es decir, una declaración de escucha que subscriptions devuelve. También aquí lo que se construye es un valor inerte.
Imagina un port con la firma de una función que recibe una cadena y devuelve un entero, resuelto en el acto. Para que eso funcionara, la evaluación de una expresión Elm tendría que suspenderse, ceder el control al motor de JavaScript, esperar y continuar con lo que le entreguen. Ese punto de suspensión es precisamente donde se cuela todo: el código externo puede lanzar, puede tardar, puede reentrar en la aplicación y llamar a update desde dentro de update. Al obligar a que la respuesta vuelva como un Msg por el mismo canal que cualquier otro evento, Elm elimina la reentrada y conserva la propiedad de que el modelo solo cambia en un lugar, de una vez y de forma ordenada.
flowchart LR U[update puro] -->|Cmd del port| RT[runtime de Elm] RT -->|dato serializado| JS[codigo JavaScript] JS -->|dato serializado| RT2[runtime de Elm] RT2 -->|Msg| U style U fill:#89b4fa,color:#11111b style JS fill:#f9e2af,color:#11111b style RT2 fill:#a6e3a1,color:#11111b
Observa la asimetría del dibujo: no hay ninguna flecha que salga de update hacia JavaScript y vuelva a update sin pasar por el runtime. El camino de vuelta es el mismo que recorre un clic del usuario o la respuesta de una petición de red. Desde el punto de vista de la lógica de la aplicación, JavaScript no es un colaborador al que se invoca, sino otra fuente de eventos externa e indistinguible de un teclado o de un reloj.
El anfitrión deja de ser un detalle y pasa a ser una pieza
Una consecuencia del diseño que suele pasar desapercibida es que obliga a reconocer al código JavaScript que rodea a la aplicación como un componente de la arquitectura, con nombre, responsabilidades y una interfaz declarada. En un proyecto híbrido convencional, ese código no existe como entidad: son fragmentos dispersos, invocados desde donde hiciera falta, sin ningún punto donde se pueda leer qué hace el sistema fuera del lenguaje principal. Con ports, en cambio, hay un archivo concreto que monta la aplicación, se suscribe a las salidas y empuja las entradas, y ese archivo es la única superficie de contacto que existe.
// El anfitrión es una pieza con contrato, no un vertedero de utilidades
const app = Elm.Main.init({
node: document.getElementById("raiz"),
flags: { api: "https://api.ejemplo.test" },
});
// Lo que la aplicación publica hacia fuera
app.ports.guardar.subscribe(function (texto) {
localStorage.setItem("nota", texto);
});
// Lo que el mundo empuja hacia dentro
window.addEventListener("online", function () {
app.ports.llegoAlgo.send("conexion recuperada");
});
Léelo como se leería la definición de una interfaz: arriba, lo que la aplicación necesita para existir; en medio, los hechos que publica; abajo, los acontecimientos que se le notifican. No hay una cuarta categoría escondida, y esa ausencia es verificable con solo abrir el archivo. Cuando alguien pregunta qué hace este sistema fuera de Elm, la respuesta cabe en una pantalla, y esa propiedad no se obtiene por disciplina del equipo sino porque el lenguaje no ofrece ninguna otra vía.
El precio del rodeo y lo que compra
Sería deshonesto presentar los ports como algo cómodo. Hacer algo que en JavaScript ocuparía una línea puede exigir aquí una declaración de port, una rama nueva en el tipo de mensajes, un caso más en update, un decodificador para lo que llega de vuelta y unas cuantas líneas de conexión en el anfitrión. El rodeo es real y quien lo niegue no está argumentando. Lo que hay que discutir es qué se compra con él, porque el intercambio es concreto y medible.
-- El rodeo completo, expuesto sin adornos
type Msg
= PulsoGuardar
| LlegoDelExterior String
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
PulsoGuardar ->
( model, guardar model.texto )
LlegoDelExterior crudo ->
-- Lo que entra es sospechoso hasta que se decodifica
( { model | ultimo = crudo }, Cmd.none )
Conviene además medir el rodeo con honestidad estadística. En una aplicación real, la cantidad de código que cruza la frontera es minúscula comparada con la que vive dentro de ella: unas pocas declaraciones frente a miles de líneas de lógica de dominio, transformación y presentación. Lo que se percibe como incomodidad se concentra en un puñado de lugares y se paga una sola vez, mientras que la garantía que compra se cobra continuamente en todo el resto. Quien evalúa el intercambio contando solo el coste está comparando un gasto puntual con un beneficio recurrente, y esa comparación siempre engaña.
Lo que se compra es esto: el conjunto de puntos del programa donde puede ocurrir algo impredecible pasa de ser difuso a ser enumerable. Puedes listar todos los ports de un proyecto con una búsqueda de texto y sabrás, con certeza, que fuera de esa lista no hay ninguna interacción con el exterior. Cuando algo falle, la investigación empieza acotada. Cuando actualices una dependencia de JavaScript, sabrás exactamente qué superficie de contacto revisar. Y cuando el compilador declare que el resto del programa no puede lanzar, no estará haciendo una estimación optimista.
Si el anfitrión nunca llama a subscribe sobre un port de salida, los datos que Elm deposite se pierden en silencio. Y si un módulo declara ports que no se usan en ninguna parte, el compilador los elimina de la salida y el objeto correspondiente no existirá en tiempo de ejecución. Ninguno de los dos casos rompe nada, pero ambos producen la desconcertante sensación de que el mensaje no llega. La frontera es segura en el sentido de que no corrompe el estado, no en el sentido de que garantice la entrega.
Conviene entender qué tipo de decisión de diseño es esta, porque se repite en muchos sistemas y casi siempre se resuelve al revés. Cuando un lenguaje ofrece garantías fuertes y necesita convivir con un ecosistema que no las tiene, tiene dos caminos. El primero es abrir una puerta de escape y confiar en la disciplina del programador: es lo que hacen las anotaciones de código externo, las aserciones de tipo y los bloques marcados como inseguros. El coste de ese camino no es que la puerta se use mal alguna vez, es que la existencia de la puerta cambia el significado lógico de todas las afirmaciones del sistema. Una garantía que admite excepciones deja de ser una garantía y pasa a ser una heurística: ya no puedes razonar por composición, porque cualquier función que llames podría, en su interior, haber cruzado la puerta. El segundo camino, el de Elm, es negarse a abrir la puerta y en su lugar construir una aduana: un punto de paso obligatorio, declarado, tipado, asíncrono y registrado en la firma del módulo. Todo lo que entra se inspecciona; todo lo que sale se despacha; nada cruza sin dejar rastro. La diferencia entre ambos caminos es la diferencia entre confiar y verificar, y se paga en incomodidad diaria a cambio de una propiedad que el primer camino no puede ofrecer a ningún precio: la posibilidad de razonar sobre el programa entero sin leerlo entero. Hay algo más, menos técnico y quizá más importante. Al obligar a que toda interacción con el exterior tenga nombre propio, tipo declarado y un lugar visible en el módulo, los ports convierten una decisión que en otros entornos es implícita y dispersa en una decisión explícita y concentrada. La arquitectura del programa deja de esconder sus dependencias impuras en el fondo de funciones auxiliares y las expone en la fachada, donde se pueden contar, discutir y cuestionar. La restricción no solo protege al compilador: protege la capacidad del equipo de saber qué está construyendo.
- Escribe la firma de la hipotética función de escape que devolvería un
Intdesde JavaScript y enumera cuatro garantías del lenguaje que quedarían anuladas por su sola existencia. - Explica por qué el daño de esa función no se limitaría a la línea donde se usa, sino que degradaría la garantía para todo el programa.
- Declara un port de salida y otro de entrada en un módulo, y razona por qué sus tipos son
CmdySuby no un valor devuelto. - Dibuja el recorrido completo de un dato que sale hacia JavaScript y vuelve, señalando en qué punto exacto deja de ser puro y en cuál vuelve a serlo.
- Busca en un proyecto tuyo de JavaScript todos los puntos de contacto con el exterior y compara ese inventario con lo que costaría obtenerlo listando los ports de un proyecto Elm.
- Defiende, con un caso concreto, por qué un port que no está conectado en el anfitrión falla en silencio en lugar de producir un error, y qué implica eso para la depuración.