wandres.dev
PORTS E INTEROP · hablar con JavaScript

Ports de entrada: recibir del exterior como una Sub

Un port de entrada invierte la firma del de salida y con ella invierte quién tiene la iniciativa. En lugar de recibir un dato y devolver un comando, recibe un constructor de mensaje y devuelve una suscripción: tú no pides nada, declaras que si algo llega quieres enterarte, y el runtime traduce esa declaración en una escucha real. Esta lección desarrolla la mecánica completa: por qué el parámetro es una función que convierte el dato entrante en un Msg, cómo el anfitrión empuja valores con send y qué ocurre si nadie está suscrito en ese instante, por qué conviene declarar el port sobre Json.Decode.Value y decodificar dentro de update en lugar de confiar en un tipo concreto, y cómo se modela el fallo de decodificación como un estado legítimo del dominio en vez de como una excepción que el lenguaje no tiene.

⏱ 18 min

Si el port de salida es un buzón donde depositas cartas, el de entrada es la ranura por la que llegan las que no pediste. La diferencia no es de dirección solamente, es de iniciativa: nadie en Elm decide cuándo entra un dato. Lo decide el anfitrión, cuando le parece, tantas veces como quiera, sin coordinación con lo que la aplicación esté haciendo. Por eso el tipo de un port de entrada no es un comando sino una suscripción, y por eso comparte naturaleza con el reloj, el teclado y el redimensionado de la ventana. Desde dentro de la aplicación, el código JavaScript del anfitrión no es un colaborador al que se consulta: es una fuente de eventos externa, tan ajena e impredecible como el usuario. Esa equiparación, que al principio parece una curiosidad de diseño, resulta ser la clave para tratar con rigor lo que llega de fuera.

🎯 Al terminar esta lección sabrás
  • Leer la firma de un port de entrada como una función que recibe un constructor de mensaje y produce una Sub.
  • Conectar el anfitrión con send y explicar qué ocurre con los datos que llegan sin escucha declarada.
  • Declarar el port sobre Json.Decode.Value y decodificar dentro de update en la frontera.
  • Modelar el fallo de decodificación como un estado del dominio y no como una excepción inexistente.

La firma invertida y por qué recibe una función

La declaración de un port de entrada desconcierta la primera vez que se lee, porque su parámetro es una función. Concretamente, una función que toma el dato entrante y devuelve un mensaje de tu vocabulario. La razón es que el port por sí solo no sabe qué significa para tu aplicación que llegue una cadena: solo tú sabes que eso debe convertirse en un LlegoTexto o en un ActualizarPreferencia. Al pasarle el constructor, le entregas la traducción, y el port devuelve una suscripción ya especializada en tu tipo de mensajes.

port module Externo exposing (main)

import Json.Decode


-- Recibe el constructor, devuelve la escucha ya tipada
port desdeFuera : (Json.Decode.Value -> msg) -> Sub msg


-- Al usarlo, el msg genérico se instancia con el tuyo
subscriptions : Model -> Sub Msg
subscriptions model =
    if model.conectado then
        desdeFuera LlegoDelExterior

    else
        Sub.none

Nota que la suscripción se declara condicionalmente, exactamente igual que cualquier otra. Si el modelo dice que la aplicación no está conectada, subscriptions no devuelve la escucha y el runtime la cancela. Los datos que el anfitrión empuje durante ese intervalo no se encolan ni se entregan después: se descartan. Escuchar sigue siendo estado derivado del modelo, y esa propiedad no cambia porque la fuente sea JavaScript en lugar de un reloj.

📥

Devuelve una Sub

No pides el dato: declaras que quieres enterarte si llega. La escucha vive mientras el modelo la implique y desaparece cuando deja de implicarla.

🎯

Recibe el constructor

El parámetro traduce el dato entrante a tu vocabulario de mensajes. El port aporta el canal; tú aportas el significado.

📡

send desde el anfitrión

El objeto de la aplicación expone un método de envío por cada port de entrada. Cada llamada produce un Msg que reentra por update.

🕵️

Todo lo que entra es sospechoso

El tipo declarado en el port es una promesa del anfitrión, no una verdad verificada. Solo un decodificador convierte la promesa en certeza.

Del send del anfitrión al update de la aplicación

Del lado de JavaScript, cada port de entrada aparece como un objeto con un método de envío. Llamarlo encola un mensaje que el runtime despachará por update en el próximo turno del bucle de eventos. El anfitrión puede llamarlo desde donde quiera: desde el manejador de un evento del navegador, desde la respuesta de una promesa, desde el oyente de un websocket o desde un temporizador. Para Elm, todas esas procedencias son indistinguibles.

const app = Elm.Main.init({ node: document.getElementById("raiz") });

// Un websocket empuja mensajes cuando quiere
const socket = new WebSocket("wss://ejemplo.test/sala");
socket.addEventListener("message", function (evento) {
  app.ports.desdeFuera.send(JSON.parse(evento.data));
});

// Otra fuente distinta, el mismo port
window.addEventListener("online", function () {
  app.ports.desdeFuera.send({ tipo: "conexion", estado: "recuperada" });
});
⚠️
Un send sin suscripción activa se pierde

No existe cola de espera. Si el anfitrión envía antes de que la aplicación haya arrancado, o mientras subscriptions no declara ese port, el valor desaparece sin aviso ni error. El caso más frecuente es enviar el estado inicial inmediatamente después de inicializar la aplicación, en el mismo turno síncrono: es demasiado pronto y el dato se pierde. Cuando lo que necesitas es configuración de arranque, la vía correcta no es un port sino un flag, que se entrega antes de que exista la primera versión del modelo. Los ports de entrada sirven para lo que ocurre después.

flowchart LR
WS[WebSocket o evento del navegador] --> H[anfitrion JS]
H -->|send| RT[runtime de Elm]
RT -->|solo si subscriptions lo declara| S[Sub del port]
S -->|Msg con Value crudo| U[update]
U -->|decodifica en la frontera| M[Model o error del dominio]
style H fill:#f9e2af,color:#11111b
style U fill:#89b4fa,color:#11111b
style M fill:#a6e3a1,color:#11111b

Decodificar en la frontera: el tipo declarado no es una verdad

Aquí está la lección más importante y la que más a menudo se aprende tarde. Nada impide declarar un port de entrada con un tipo concreto, por ejemplo una función que reciba un entero y devuelva un mensaje. El compilador lo aceptará y el código quedará más corto. El problema es que ese tipo no lo verifica nadie: es una promesa que el anfitrión puede incumplir, y si envía una cadena donde prometía un entero, el valor entrará en el modelo con un tipo mentido y el programa fallará más tarde, en un punto arbitrario y sin relación aparente con la causa. Es precisamente la clase de error que Elm existe para eliminar.

La disciplina correcta es declarar el port sobre Json.Decode.Value, que es el tipo de lo desconocido, y decodificar en cuanto el mensaje llega a update. Así la sospecha vive en un solo lugar, se resuelve inmediatamente y el modelo solo recibe valores cuya forma se ha comprobado.

type Msg
    = LlegoDelExterior Json.Decode.Value


type alias Aviso =
    { titulo : String, urgente : Bool }


decodificador : Json.Decode.Decoder Aviso
decodificador =
    Json.Decode.map2 Aviso
        (Json.Decode.field "titulo" Json.Decode.string)
        (Json.Decode.field "urgente" Json.Decode.bool)


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        LlegoDelExterior crudo ->
            case Json.Decode.decodeValue decodificador crudo of
                Ok aviso ->
                    ( { model | avisos = aviso :: model.avisos }, Cmd.none )

                Err error ->
                    -- El anfitrión incumplió el contrato: es un caso del dominio
                    ( { model | ultimoFallo = Just (Json.Decode.errorToString error) }
                    , Cmd.none
                    )
💡
El error de decodificación no es un accidente: es una rama del diseño

Que decodeValue devuelva un Result obliga a decidir qué hacer cuando el exterior manda basura, y esa obligación es un regalo disfrazado de molestia. En un sistema sin tipos la pregunta ni siquiera se formula y el fallo se manifiesta como un comportamiento raro tres pantallas más allá. Aquí tienes que responderla en el mismo sitio donde ocurre: ignorar el mensaje, registrar el fallo, mostrar un aviso al usuario o entrar en un estado degradado. Cualquiera de las cuatro es defendible. Lo que no es posible es no haberlo pensado, y esa imposibilidad vale más que la brevedad que se pierde.

El anfitrión no es tu programa: es el mundo, y el mundo no cumple contratos

Conviene detenerse en la equiparación que hace esta lección, porque cambia la postura con la que se escribe todo el código de frontera. Cuando declaras un port de entrada, Elm te está diciendo que el JavaScript que rodea a tu aplicación pertenece a la misma categoría ontológica que el usuario, la red y el reloj del sistema: es exterior, es asíncrono, es impredecible y no está sujeto a tus garantías. Esa afirmación resulta incómoda porque ese JavaScript lo escribiste tú, probablemente hace diez minutos, en el archivo de al lado. La intuición protesta: si lo escribí yo, sé lo que envía. Pero la intuición confunde dos cosas distintas. Saber lo que envía hoy no es lo mismo que tener una garantía de lo que enviará; y la diferencia entre ambas no es de probabilidad sino de naturaleza. El compilador de Elm verifica todo lo que compila. Todo lo demás, sin excepción y con independencia de quién lo haya escrito, es una fuente de datos no verificada, y tratarla como si fuera de confianza no la vuelve confiable: solo desplaza el momento del descubrimiento. La consecuencia práctica de tomarse esto en serio es una arquitectura con una piel bien definida. Dentro de la piel, los tipos son verdades demostradas y puedes razonar por composición sin releer nada. Fuera de la piel, todo es promesa. Y la piel misma, esa capa delgada donde viven los decodificadores, es el único lugar del programa donde se hace el trabajo de convertir promesas en verdades. Los sistemas que envejecen bien son los que tienen esa capa nítida y delgada; los que envejecen mal son los que la dejan difusa, con conversiones dispersas y suposiciones repartidas por todas partes. Un port de entrada declarado sobre Value y decodificado en update no es paranoia: es la decisión de tener una piel en lugar de tener poros.

⚔️ Recibe lo desconocido y conviértelo en certeza
  1. Declara un port de entrada sobre Json.Decode.Value y explica por qué su parámetro es una función y no el dato directamente.
  2. Haz que subscriptions devuelva esa escucha solo cuando el modelo esté en cierto estado, y comprueba que los envíos hechos fuera de ese intervalo se pierden.
  3. Envía desde el anfitrión un valor que incumpla el contrato del decodificador y decide, justificándolo, cuál de las cuatro respuestas posibles corresponde a tu dominio.
  4. Reescribe un port declarado con un tipo concreto para que use Value y enumera qué clase de fallo se ha vuelto imposible con el cambio.
  5. Conecta dos fuentes distintas de JavaScript al mismo port y razona por qué la aplicación no puede ni debe distinguir su procedencia.
  6. Argumenta por qué la configuración inicial no debe entrar por un port de entrada, y qué mecanismo le corresponde en su lugar.