Ports de salida: mandar datos a JavaScript desde un Cmd
Un port de salida es la mitad más sencilla de la frontera y también la más reveladora, porque su forma delata toda la filosofía del diseño. Se declara como una función que recibe un dato y devuelve un Cmd, lo que significa que invocarla no envía nada: construye una descripción de envío que update entrega al runtime junto con el modelo nuevo. Esta lección desarrolla el ciclo completo con el ejemplo canónico de persistir estado en el almacenamiento del navegador. Veremos qué tipos pueden cruzar la frontera y por qué la restricción no es caprichosa, cómo se escribe el lado del anfitrión con subscribe y por qué admite varios oyentes, qué significa que el envío sea sin retorno y cómo se obtiene confirmación cuando hace falta, y por qué conviene diseñar un único port con un mensaje etiquetado en lugar de multiplicar declaraciones para cada operación.
De las dos direcciones de la frontera, la de salida es la que primero se necesita y la que mejor enseña el mecanismo, porque en ella la iniciativa es tuya y el recorrido del dato se sigue sin ambigüedad. Quieres que algo ocurra fuera de Elm: guardar una preferencia, copiar al portapapeles, pedir a una biblioteca de gráficas que se redibuje, escribir en un registro de telemetría. La tentación es pensar en ello como en una llamada, y el nombre no ayuda porque un port de salida se declara con la apariencia de una función corriente. Pero la firma dice otra cosa: no devuelve el efecto realizado, devuelve un Cmd. Es decir, devuelve un valor inerte que describe un envío y que no hará nada hasta que update lo entregue al runtime. Entender esa distinción es entender por qué el mecanismo preserva la pureza que el capítulo anterior defendía.
- Declarar un port de salida y leer su firma como constructor de un
Cmd, no como ejecución inmediata. - Enumerar qué tipos pueden cruzar la frontera y explicar por qué la lista está restringida.
- Conectar el lado del anfitrión con
subscribey describir el ciclo completo de un dato que sale. - Diseñar un port único con carga etiquetada en lugar de multiplicar declaraciones por operación.
La firma delata la naturaleza del envío
Un port de salida se declara en un módulo marcado como port module y consiste en una firma sin cuerpo: el compilador genera la implementación. Lo que tú escribes es el nombre, el tipo del dato que quieres mandar y el tipo de retorno, que siempre es un comando. Fíjate en que el parámetro del comando se deja genérico: como el envío no produce ninguna respuesta que reentre en la aplicación, ese comando no puede generar mensajes de ningún vocabulario concreto, y por tanto encaja en cualquiera.
port module Almacen exposing (guardarNota, borrarNota)
-- Un dato sale, ningún mensaje vuelve: por eso el msg es libre
port guardarNota : String -> Cmd msg
-- Sin carga útil, se envía la unidad
port borrarNota : () -> Cmd msg
-- Con varios campos, un record de tipos permitidos
port registrarEvento : { nombre : String, valor : Int } -> Cmd msg
La restricción sobre qué puede cruzar es tan importante como la firma. Por un port viajan valores que el runtime sabe convertir a datos de JavaScript sin ambigüedad: números, cadenas, booleanos, la unidad, listas y arreglos, Maybe, records cuyos campos sean a su vez de tipos permitidos, y Json.Encode.Value para lo demás. Lo que no puede cruzar es tan revelador como lo que sí: no cruzan funciones, porque una función que atravesara la frontera sería exactamente la puerta de ejecución arbitraria que el diseño evita, y no cruzan tipos unión definidos por ti, porque su representación interna es un detalle del compilador que ningún contrato externo debería fijar.
Devuelve un Cmd
Llamar al port construye una descripción. El envío ocurre cuando update devuelve ese comando al runtime, nunca antes y nunca dentro de una expresión pura.
Tipos permitidos
Números, cadenas, booleanos, listas, records, Maybe y Value. Ni funciones ni uniones propias: la frontera transporta datos, no representaciones internas.
Sin retorno
El port de salida no informa de nada. Si necesitas saber si funcionó, el anfitrión debe responder por un port de entrada, como haría cualquier fuente externa.
Falla en silencio
Si nadie hizo subscribe en el anfitrión, el dato se pierde sin error. La frontera garantiza que no se corrompe el estado, no que el mensaje llegue.
El ciclo completo: persistir estado en el navegador
El caso canónico es guardar una parte del modelo en el almacenamiento local del navegador para recuperarla en la siguiente visita. Elm no tiene acceso a esa API y no lo tendrá, porque es una API imperativa, síncrona y con efectos sobre un espacio compartido: justo el tipo de superficie que el lenguaje delega en el anfitrión. El diseño habitual codifica la porción relevante del modelo, la manda por el port y deja que unas pocas líneas de JavaScript hagan la escritura.
port guardarEstado : Json.Encode.Value -> Cmd msg
codificar : Model -> Json.Encode.Value
codificar model =
Json.Encode.object
[ ( "tema", Json.Encode.string model.tema )
, ( "volumen", Json.Encode.int model.volumen )
]
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
CambioTema nuevo ->
let
siguiente =
{ model | tema = nuevo }
in
-- El modelo nuevo se persiste, no el viejo
( siguiente, guardarEstado (codificar siguiente) )
Silenciar ->
( { model | volumen = 0 }, Cmd.none )
Del lado del anfitrión, el objeto de la aplicación expone cada port de salida con un método de suscripción al que se le pasa la función que recibirá el dato. Esa función se ejecuta cada vez que el runtime despacha un comando de ese port, con el valor ya convertido a un objeto ordinario de JavaScript.
const app = Elm.Main.init({
node: document.getElementById("raiz"),
});
app.ports.guardarEstado.subscribe(function (datos) {
try {
localStorage.setItem("estado", JSON.stringify(datos));
} catch (e) {
// El almacenamiento puede estar lleno o bloqueado
app.ports.fallo.send("no se pudo guardar");
}
});
El método subscribe no reemplaza al oyente anterior: los acumula, de modo que un mismo port puede alimentar a la vez la persistencia y un registro de depuración. Existe también unsubscribe, al que hay que pasarle la misma referencia de función que se registró, no una equivalente. Esta parte del contrato vive por completo en JavaScript y por tanto queda fuera de las garantías del compilador: si registras dos veces la misma función por error, se ejecutará dos veces por envío. Es un buen recordatorio de que la disciplina termina exactamente en la frontera.
sequenceDiagram participant U as update participant R as runtime participant J as anfitrion JS participant S as almacenamiento U->>R: Cmd de guardarEstado R->>J: dato ya convertido J->>S: escritura imperativa S-->>J: exito o excepcion J->>R: send por port de entrada R->>U: Msg de confirmacion
Sin retorno: cómo se obtiene confirmación
Un port de salida no informa de nada. No devuelve un valor, no produce un mensaje de éxito y no falla si el otro lado revienta: la excepción que lance el oyente de JavaScript se propaga por el bucle de eventos del navegador y la aplicación Elm ni se entera. Esta asimetría desconcierta al principio, pero es la consecuencia directa de que el envío sea un Cmd y no una petición. Cuando necesitas saber si la operación funcionó, la respuesta no llega por donde salió: llega como cualquier otro evento externo, es decir, por un port de entrada que produce una Sub y termina en un Msg.
El diseño resultante conviene planificarlo desde el principio en lugar de improvisarlo. Si cada operación externa tiene su propio port de salida y su propio port de respuesta, un proyecto mediano acumula veinte declaraciones y una superficie de contacto difícil de auditar. La alternativa más limpia es un solo port de salida que transporte un valor con una etiqueta y una carga, y un solo port de entrada que devuelva respuestas con la misma forma. El anfitrión se convierte entonces en un pequeño despachador y la frontera queda reducida a dos líneas en la fachada del módulo.
-- Una sola puerta, con la operación etiquetada dentro
port haciaFuera : Json.Encode.Value -> Cmd msg
peticion : String -> Json.Encode.Value -> Json.Encode.Value
peticion etiqueta carga =
Json.Encode.object
[ ( "op", Json.Encode.string etiqueta )
, ( "datos", carga )
]
guardar : Model -> Cmd msg
guardar model =
haciaFuera (peticion "guardar" (codificar model))
Si update devuelve varios comandos con Cmd.batch, el runtime los despacha en el orden en que aparecen en la lista, pero cualquier trabajo asíncrono que el anfitrión inicie a partir de ellos termina cuando termina. Dos escrituras lanzadas seguidas pueden completarse en orden inverso si el JavaScript que las atiende usa promesas. No asumas secuencialidad al otro lado de la frontera: si una operación depende de que otra haya terminado, esa dependencia debe expresarse encadenando por mensajes, no confiando en el orden de despacho.
Hay un desplazamiento conceptual escondido en la palabra port que merece nombrarse, porque cambia por completo la manera de diseñar la relación con el anfitrión. En el modelo mental habitual, cuando un programa necesita algo del sistema le da una orden y espera obediencia: escribe esto, abre aquello, devuélveme el resultado. La relación es jerárquica y el llamante conserva el control durante toda la operación. Un port de salida no funciona así. La aplicación Elm no ordena nada: publica un hecho en un canal y sigue con su vida. Ni siquiera sabe si hay alguien escuchando, y esa ignorancia no es un defecto que haya que remediar sino la propiedad que hace robusto al sistema. Porque en el momento en que la aplicación dejara de publicar y pasara a ordenar, tendría que saber a quién ordena, qué puede fallar en la ejecución y cómo esperar el resultado, y ese conocimiento la ataría al anfitrión concreto. Publicando, en cambio, permanece indiferente: el mismo programa funciona con un anfitrión que persiste en el navegador, con otro que persiste en un servidor, con uno de pruebas que solo registra lo que le llega y con ninguno en absoluto. Lo impuro no está solo aislado, está desacoplado, y son dos cosas distintas que suelen confundirse. La consecuencia práctica es que el diseño correcto de un port no describe una acción sino un acontecimiento. Un port llamado guardar el estado envejece mal, porque codifica en Elm una decisión que pertenece al anfitrión; un port llamado el estado ha cambiado envejece bien, porque enuncia un hecho verdadero con independencia de quién lo escuche y de qué decida hacer con él. Nombrar bien la frontera es, en última instancia, decidir qué parte del sistema tiene derecho a saber qué.
- Declara un port de salida que reciba un
Json.Encode.Valuey explica por qué su tipo de retorno lleva unmsggenérico en lugar de tu tipo de mensajes. - Escribe la función que codifica dos campos del modelo y comprueba que en
updatepersistes el modelo nuevo y no el que recibiste como argumento. - Conecta el anfitrión con
subscribey provoca deliberadamente una excepción dentro del oyente; observa que la aplicación Elm sigue funcionando sin enterarse. - Registra dos oyentes sobre el mismo port y razona qué implica para la idempotencia de la operación que ambos se ejecuten.
- Rediseña tres ports de operaciones distintas en uno solo con carga etiquetada, y evalúa qué se gana y qué se pierde en legibilidad.
- Renombra tus ports para que enuncien hechos en lugar de órdenes y comprueba cuáles de ellos dejan de tener sentido, señal de que codificaban una decisión ajena.