wandres.dev
ELM EN PRODUCCIÓN · build y despliegue

Integrar con el tooling moderno: Vite o webpack, recarga en caliente y convivencia con TypeScript

Un lenguaje que compila a JavaScript no vive solo: tiene que entrar en un grafo de módulos gobernado por un empaquetador, participar en un servidor de desarrollo que recarga sin perder el estado, y convivir con un código anfitrión que casi siempre está escrito en TypeScript. Esta lección estudia esa integración en sus tres frentes. Primero, cómo un complemento convierte al compilador de Elm en un paso más del grafo del empaquetador y qué implica que la unidad de compilación sea el programa entero y no el archivo. Segundo, qué significa realmente la recarga en caliente cuando el estado de la aplicación es un único valor inmutable, por qué eso permite conservar el modelo entre recargas con una fidelidad que otros sistemas no alcanzan, y en qué casos exactos esa conservación es imposible y hay que reiniciar. Tercero, cómo se tipa el borde entre los dos mundos, por qué el compilador de TypeScript no puede ver lo que ocurre al otro lado de un puerto y qué estrategias existen para que ese borde no se convierta en el punto ciego del sistema.

⏱ 17 min

Hay una tentación comprensible al adoptar un lenguaje que trae su propio compilador y su propio gestor de paquetes, y es tratarlo como una isla: una carpeta aparte, un comando aparte, un artefacto que se copia al final. Funciona, y durante un tiempo parece limpio. Lo que se pierde por el camino es todo aquello que el resto del equipo da por supuesto: que al guardar un archivo el navegador refleje el cambio en menos de un segundo, que las hojas de estilo y las imágenes pasen por el mismo proceso, que la salida lleve huella de contenido en el nombre, que el editor señale un error de tipos en la frontera entre los dos lenguajes. Integrar Elm en un empaquetador moderno no es una comodidad opcional sino la diferencia entre una tecnología que el equipo adopta y una que tolera. La buena noticia es que la integración es sorprendentemente sencilla, porque el compilador de Elm hace una sola cosa y la hace de forma predecible. La menos buena es que el borde entre los dos sistemas de tipos sigue siendo un borde, y ninguna herramienta lo cierra del todo.

🎯 Al terminar esta lección sabrás
  • Configurar el compilador de Elm como un paso del grafo de módulos en un empaquetador moderno y explicar qué unidad compila realmente.
  • Justificar por qué la recarga en caliente puede conservar el modelo en Elm y enumerar los casos en los que forzosamente se pierde.
  • Tipar el borde entre el anfitrión y el programa compilado mediante declaraciones para las banderas y para cada puerto.
  • Decidir con criterio entre integrar el compilador en el empaquetador o mantenerlo como proceso independiente.

El compilador como un nodo más del grafo

La integración se apoya en una idea simple: hacer que importar un archivo fuente de Elm desde el código anfitrión sea una importación normal, y que el complemento del empaquetador se encargue de invocar al compilador y devolver el módulo resultante. Lo que se obtiene de esa importación es un objeto con un constructor por cada programa compilado, y ese constructor recibe el nodo del documento donde montar y las banderas de arranque. A partir de ahí todo el resto de la maquinaria del empaquetador funciona sin saber que hay otro lenguaje debajo: la salida entra en la división de paquetes, recibe su huella de contenido en el nombre, participa en la sustitución de variables de entorno y se sirve por el mismo servidor de desarrollo.

import { defineConfig } from "vite";
import elm from "vite-plugin-elm";

export default defineConfig({
  plugins: [
    elm({ debug: true, optimize: false }),
  ],
  build: { target: "es2018" },
});

Hay un detalle que conviene tener presente porque desconcierta a quien viene de otros lenguajes: la unidad de compilación de Elm no es el archivo sino el programa completo alcanzable desde el punto de entrada. El compilador no traduce módulo a módulo para que el empaquetador los una después; recibe un archivo raíz, resuelve el grafo entero por su cuenta usando las rutas declaradas en el archivo de dependencias, y emite un único bloque de JavaScript. Esto significa que el empaquetador no puede dividir el código de Elm por rutas ni cargar partes bajo demanda del modo habitual, y que la caché de módulos del servidor de desarrollo opera con una granularidad más gruesa. A cambio, la compilación incremental del propio compilador es rápida, y el resultado se mantiene por debajo del umbral en que la espera se nota.

De esa granularidad se sigue una regla de configuración que ahorra bastantes horas de desconcierto: el complemento tiene que recibir la lista de directorios de fuentes que el archivo de dependencias declara, y el vigilante del servidor de desarrollo tiene que observar esos directorios enteros y no solo el archivo importado. Si se deja la vigilancia en manos del grafo del empaquetador, editar un módulo profundo que nadie importa desde JavaScript no dispara ninguna recompilación, y el navegador sigue mostrando código viejo sin decir nada. Es el fallo de integración más frecuente y el más difícil de diagnosticar, porque no produce ningún error: simplemente el cambio no aparece.

export default defineConfig({
  plugins: [ elm({ debug: true, optimize: false }) ],
  server: {
    watch: { ignored: ["!**/src/**/*.elm", "**/elm-stuff/**"] },
  },
});
import { Elm } from "./src/Main.elm";

const raiz = document.getElementById("panel");

const app = Elm.Main.init({
  node: raiz,
  flags: {
    apiUrl: import.meta.env.VITE_API_URL,
    usuario: null,
  },
});

Vite

Complemento breve, arranque inmediato y sustitución de módulos en caliente lista sin configuración adicional.

📦

webpack

El cargador clásico, con más superficie de configuración y presencia sólida en proyectos heredados grandes.

👀

Proceso aparte

Un vigilante dedicado que solo compila Elm y deja al empaquetador el resto; menos acoplamiento, mejores mensajes de error.

🧩

Un artefacto

El compilador emite un bloque único por punto de entrada: no hay división automática por rutas dentro del código de Elm.

Recarga en caliente cuando el estado es un solo valor

La sustitución de módulos en caliente es un problema difícil en la mayoría de los entornos porque el estado está repartido: vive dentro de instancias de componentes, en cierres, en variables de módulo, en referencias mutables que nadie enumeró nunca. Sustituir el código sin perder el estado exige adivinar qué estado pertenecía a qué pieza, y por eso la experiencia real oscila entre lo mágico y lo desconcertante. En Elm la pregunta cambia de naturaleza, porque todo el estado de la aplicación es un único valor inmutable que el entorno de ejecución tiene en la mano. Conservar el estado a través de una recarga consiste en serializar ese valor antes de sustituir el código y volver a inyectarlo después, y no hay nada que adivinar.

💡
Cuándo el modelo sobrevive y cuándo no puede sobrevivir

La conservación funciona mientras la forma del modelo no cambie. Si solo se ha tocado el cuerpo de una función de vista, un cálculo o una rama de la actualización, el valor guardado sigue siendo válido y la aplicación reaparece exactamente donde estaba, lo cual es de un valor práctico enorme cuando se está afinando una pantalla que cuesta seis clics alcanzar. Si en cambio se ha añadido un campo al registro del modelo, se ha cambiado el tipo de uno existente o se ha añadido o quitado un constructor al tipo de mensajes, el valor antiguo ya no encaja en el tipo nuevo, y reinyectarlo produciría precisamente la clase de estado imposible que el lenguaje entero existe para evitar. En ese caso lo correcto es reiniciar desde el estado inicial, y las herramientas lo hacen. Merece la pena entender la regla porque explica una asimetría que al principio parece caprichosa: editar la vista conserva el estado, editar el modelo lo descarta, y ambas conductas son la respuesta correcta.

Hay un segundo aspecto de la recarga que decide la experiencia diaria y que casi nunca se configura con cuidado: qué ocurre cuando la compilación falla. La opción por defecto de muchos montajes es dejar la página como estaba y escribir el error en la terminal, lo cual funciona para quien tiene la terminal a la vista y desconcierta a quien no. La alternativa, que la mayoría de los complementos ofrecen, consiste en pintar el mensaje del compilador como una superposición sobre la aplicación. Vale la pena activarla por una razón que va más allá de la comodidad: los mensajes de este compilador son, junto con la exhaustividad, el activo pedagógico más grande del lenguaje, y esconderlos en una ventana que nadie mira desperdicia buena parte de lo que se vino a buscar.

flowchart TD
A[Guardar un archivo] --> B[El complemento invoca al compilador]
B --> C[Error de tipos]
B --> D[Compilacion correcta]
C --> E[Superposicion con el mensaje del compilador]
D --> F[Cambio solo en vista o logica]
D --> G[Cambio en la forma del modelo]
F --> H[Serializar modelo y reinyectar]
G --> I[Reinicio desde el estado inicial]
style D fill:#a6e3a1,color:#11111b
style C fill:#f38ba8,color:#11111b
style H fill:#89b4fa,color:#11111b

Tipar el borde con TypeScript

El punto delicado de la convivencia no es la construcción sino la frontera. El compilador de Elm garantiza todo lo que ocurre dentro de su grafo, y el de TypeScript todo lo que ocurre dentro del suyo, pero entre ambos hay tres pasos por los que viajan datos sin que ninguno de los dos los verifique: las banderas de arranque, los mensajes que salen por un puerto y los que entran. Del lado de Elm, las banderas y los puertos de entrada se decodifican, de modo que un dato mal formado produce un fallo explícito y localizado en lugar de propagarse. Del lado de TypeScript no hay nada equivalente por defecto: el módulo importado es un valor sin tipo y cualquier error de nombre o de forma pasa desapercibido hasta que la aplicación se ejecuta.

declare module "*.elm" {
  export namespace Elm {
    namespace Main {
      type Banderas = {
        apiUrl: string;
        usuario: string | null;
      };
      type App = {
        ports: {
          guardarEstado: { subscribe: (cb: (dato: string) => void) => void };
          recibirNotificacion: { send: (dato: unknown) => void };
        };
      };
      function init(opciones: { node: HTMLElement; flags: Banderas }): App;
    }
  }
}

Escribir a mano ese archivo de declaración funciona y es mejor que nada, pero tiene el defecto obvio de toda duplicación: cuando alguien cambia la firma de un puerto en Elm, la declaración se queda vieja y el error reaparece exactamente donde se creía haberlo eliminado. La alternativa madura consiste en generar las declaraciones a partir del código de Elm como paso de la construcción, de modo que la única fuente de verdad siga siendo el archivo fuente y la desincronización sea imposible. Existen herramientas de la comunidad dedicadas a ello, y algunas van más lejos y generan también los codificadores y decodificadores emparejados para que el formato de cada mensaje esté descrito una sola vez.

⚠️
Tipar el borde no lo hace seguro, solo lo hace visible

Conviene no confundir una declaración de tipos con una verificación. El archivo de declaración le dice al compilador de TypeScript qué forma tienen los puertos, y con eso evita errores de nombre, de aridad y de forma en el código anfitrión. Lo que no hace, en ningún caso, es comprobar que el dato que realmente cruza la frontera en tiempo de ejecución tenga esa forma: si un valor llega de la red, de almacenamiento local o de una biblioteca de terceros y no coincide con lo declarado, TypeScript ya se fue y no queda nadie mirando. La defensa efectiva sigue estando del lado de Elm, en el decodificador que valida cada mensaje entrante y produce un fallo explícito. La regla práctica es escribir el decodificador primero y derivar el tipo de TypeScript de él, nunca al revés.

Queda por resolver una cuestión organizativa que suele decidirse por inercia y que conviene decidir a propósito: si el compilador de Elm debe vivir dentro del empaquetador o al lado. La integración por complemento gana en comodidad, porque hay un solo comando y un solo servidor, y pierde en calidad de diagnóstico, porque el mensaje del compilador atraviesa una capa que a veces lo recorta y a veces lo convierte en un error genérico del empaquetador. El proceso independiente invierte el balance: dos comandos que arrancar, pero el mensaje llega íntegro, la recompilación no compite con el resto del grafo y una avería en el complemento deja de ser un problema. En equipos donde varias personas están aprendiendo el lenguaje, el segundo modelo suele rendir más, precisamente porque el compilador es el profesor.

# Modelo integrado: un solo proceso
npm run dev

# Modelo separado: el compilador vigila por su cuenta
elm-watch hot &
vite --port 5173
Una tecnología se adopta por su borde, no por su centro

Hay una lección de sociología de la ingeniería escondida en esta lección técnica, y creo que explica más adopciones y más rechazos que cualquier comparación de características. Un lenguaje se evalúa por su centro cuando lo estudia una persona, y por su borde cuando lo adopta un equipo. El centro de Elm es extraordinario y el track entero se ha dedicado a mostrarlo: exhaustividad, ausencia de nulos, efectos como datos, refactorización sin miedo. Pero el día que un equipo decide si esto entra en el producto, ninguna de esas propiedades está sobre la mesa. Lo que está sobre la mesa es si el servidor de desarrollo sigue siendo instantáneo, si el editor sigue señalando errores donde antes lo hacía, si el proceso de despliegue cambia, si la persona nueva puede arrancar el proyecto el primer día, y si cuando algo falle a las tres de la tarde de un viernes habrá una forma conocida de averiguar qué pasó. Un borde áspero convierte una tecnología superior en una tecnología rechazada, y la historia del software está llena de casos donde ganó la opción peor con mejor integración. De ahí se sigue una consecuencia práctica para quien introduzca algo nuevo en un equipo, y es que el trabajo de integración no es el trabajo secundario que se hace después de la parte interesante: es la parte que decide el resultado. Merece la pena invertir en el complemento, en el archivo de declaración generado, en el mensaje de error que aparece en pantalla, en el guion que arranca todo con un solo comando. Y merece la pena por una razón que va más allá de la comodidad: un borde bien construido es también el sitio donde se documenta el contrato entre dos sistemas que nadie más va a documentar. Cada puerto tipado y cada bandera declarada es una frontera que dejó de ser folclore oral para convertirse en algo que una herramienta comprueba.

⚔️ Construye la integración que tu equipo aceptaría
  1. Monta un proyecto con un empaquetador moderno donde el archivo fuente de Elm se importe como un módulo más y verifica que la huella de contenido aparece en el nombre del artefacto.
  2. Edita una función de vista y comprueba que el estado sobrevive; después añade un campo al modelo y observa el reinicio, explicando por qué reinyectar el valor antiguo habría sido incorrecto.
  3. Provoca un error de tipos y evalúa cómo se presenta en el navegador; compáralo con el mensaje del compilador en la terminal y decide cuál quieres que vea tu equipo.
  4. Escribe a mano el archivo de declaración para las banderas y los puertos, y luego cambia la firma de un puerto sin actualizarlo para medir cuánto tarda el fallo en manifestarse.
  5. Sustituye ese archivo por uno generado a partir del código fuente y razona qué clase entera de fallos acaba de desaparecer.
  6. Compara la integración por complemento con un proceso vigilante independiente en un proyecto real, y argumenta cuál elegirías atendiendo a la calidad de los mensajes de error y no solo a la velocidad.