wandres.dev
ELM FRENTE AL ECOSISTEMA · trade-offs honestos

Elm frente a TypeScript y React: la comparación honesta

La comparación entre Elm y el par formado por TypeScript y React se libra casi siempre en el peor terreno posible: el de las anécdotas cruzadas, donde un bando exhibe un error de runtime que su tipo debió haber impedido y el otro exhibe una librería de gráficos que existe en un ecosistema y no en el otro. Esta lección propone desmontar esa discusión y rehacerla sobre cuatro ejes que no se pueden mezclar sin producir sinsentidos: qué garantiza cada tecnología y con qué agujeros, cuánto cuesta escribir y modificar código en cada una, qué hay disponible sin escribirlo uno mismo, y a qué velocidad avanza un equipo en la primera semana frente a la velocidad a la que avanza en el decimoctavo mes. Se examina la diferencia estructural entre un sistema de tipos que anota un lenguaje que no controla y otro que define el lenguaje entero, por qué esa diferencia produce agujeros que ninguna configuración estricta cierra, dónde paga Elm su disciplina con verbosidad real, y en qué escenarios la respuesta honesta es que TypeScript con React es la elección correcta. El objetivo no es coronar a un ganador sino dejar al lector capaz de defender cualquiera de las dos posiciones con los datos exactos.

⏱ 18 min

Hay una razón por la que esta discusión no avanza casi nunca, y no es la mala fe de los participantes. Es que las dos partes están midiendo cosas distintas con la misma palabra. Cuando alguien dice que TypeScript es seguro está diciendo, con toda razón, que su editor le avisa cuando escribe mal el nombre de un campo y que puede renombrar un símbolo en veinte archivos sin miedo. Cuando alguien dice que Elm es seguro está diciendo algo de una naturaleza completamente diferente: que existe una clase entera de fallos que su programa compilado no puede exhibir, no porque nadie los haya escrito todavía, sino porque no hay forma de expresarlos. Lo primero es una propiedad de la experiencia de desarrollo; lo segundo es una propiedad del artefacto que se despliega. Ambas son valiosas, ninguna sustituye a la otra, y confundirlas produce conversaciones en las que uno responde con herramientas a un argumento sobre garantías. Esta lección separa los ejes con cuidado, concede sin regatear todo lo que hay que conceder, y deja la comparación en el único sitio donde puede resolverse: el de un proyecto concreto con un equipo concreto y un horizonte concreto.

🎯 Al terminar esta lección sabrás
  • Distinguir entre tipos que anotan un lenguaje ajeno y tipos que definen el lenguaje, y derivar de ahí por qué unos tienen agujeros irreducibles.
  • Enumerar con precisión las vías por las que un valor inesperado entra en un programa de TypeScript pese a la configuración más estricta.
  • Medir el coste ergonómico real de Elm sin exagerarlo ni minimizarlo, distinguiendo verbosidad de complejidad.
  • Reconocer los escenarios en los que la respuesta honesta es que TypeScript con React es la elección correcta.

Dos sistemas de tipos que no juegan al mismo juego

La diferencia decisiva no está en la expresividad de las anotaciones, donde TypeScript es en muchos aspectos más rico, sino en su relación con la ejecución. Los tipos de TypeScript se borran antes de ejecutar: describen un programa de JavaScript que existirá con o sin ellos, y cuya semántica no controlan en absoluto. Los tipos de Elm no se borran en ese sentido, porque el compilador es el único camino hacia el artefacto ejecutable y usa la información de tipos para decidir qué compila y qué no. De esa asimetría se sigue todo lo demás. Un sistema que anota puede ser derrotado por cualquier valor que entre sin pasar por la anotación; un sistema que define no tiene por dónde ser derrotado, porque no existe la categoría de valor que llegó sin declararse.

La consecuencia práctica se ve mejor enumerando las puertas concretas. La primera es any, que la disciplina de equipo puede prohibir. La segunda es la aserción de tipo, esa construcción que le dice al compilador que confíe; la disciplina también puede prohibirla, con más dificultad. La tercera es la frontera con el mundo: la función que analiza JSON devuelve un valor sin estructura conocida y la anotación que se le pone encima es una promesa que nadie verifica en tiempo de ejecución. La cuarta son las declaraciones de tipos de terceros, escritas a mano para librerías que no las tenían, que pueden mentir y a veces mienten. La quinta es la interoperación con cualquier código que no pasó por el compilador. Ninguna de las cinco se cierra con una opción de configuración, porque las cinco son consecuencia directa de que el lenguaje base admite valores que el sistema de tipos no vio nacer.

// La anotacion es una promesa, no una comprobacion
const usuario = JSON.parse(cuerpo) as Usuario;
console.log(usuario.perfil.nombre.toUpperCase());
// Si el servidor omitio perfil, esto falla en produccion
// y ninguna configuracion estricta lo habria impedido
-- El decoder es codigo que se ejecuta y puede fallar de forma explicita
decodificarUsuario : Decoder Usuario
decodificarUsuario =
    Decode.map2 Usuario
        (Decode.field "id" Decode.int)
        (Decode.at [ "perfil", "nombre" ] Decode.string)

-- El resultado es Result Error Usuario y el compilador
-- obliga a tratar la rama del fallo antes de seguir
Eje TypeScript con React Elm
Alcance de los tipos Anotan un lenguaje que existe sin ellos Definen el lenguaje entero
Frontera con JSON Aserción no verificada Decoder ejecutable con fallo explícito
Ausencia de valor null, undefined, opcional, A | undefined Un solo Maybe sin alternativas
Exhaustividad Opcional y evadible Obligatoria en cada case
Efectos Cualquier función puede producirlos Solo el runtime, a partir de valores
Refactor grande Guiado, con puntos ciegos reales Guiado y sin puntos ciegos dentro del proyecto

Hay una segunda asimetría, más sutil, que se refiere a la exhaustividad. TypeScript puede comprobar que un switch cubre todas las variantes de una unión discriminada, y lo hace bien, pero solo si alguien escribe deliberadamente la comprobación mediante el tipo vacío en la rama por defecto. Es una técnica que hay que conocer, recordar y repetir en cada punto de decisión, y basta omitirla una vez para que añadir una variante nueva compile sin protestar y falle en silencio en la única pantalla donde nadie la trató. En Elm la exhaustividad no es una técnica sino la única forma de escribir un case, y por tanto no depende de que nadie se acuerde. La diferencia entre una comprobación que hay que invocar y una comprobación que no se puede evitar parece pequeña sobre un ejemplo y es enorme sobre un dominio con cuarenta variantes repartidas en cien módulos.

// Exhaustividad en TypeScript: funciona, pero hay que pedirla
function ver(e: Estado): string {
  switch (e.tipo) {
    case "cargando": return "Cargando";
    case "cargado": return e.datos.nombre;
    default: {
      const imposible: never = e; // si falta una variante, aqui falla
      return imposible;
    }
  }
}
⚖️

Anotar frente a definir

Los tipos de TypeScript describen un lenguaje que existe sin ellos; los de Elm deciden qué llega a compilarse.

🚪

Cinco puertas abiertas

any, la aserción, la frontera con JSON, las declaraciones de terceros y el código sin compilar.

⏱️

Dos relojes distintos

Velocidad altísima la primera semana frente a pendiente plana en el mes dieciocho.

🧰

Ecosistema

Aquí no hay debate: el catálogo de npm no tiene equivalente y hay piezas que no se sustituyen.

El coste ergonómico y los dos relojes

Sería deshonesto presentar la disciplina de Elm como gratuita. Leer un campo de una respuesta del servidor cuesta una línea en TypeScript y varias en Elm, y esas varias hay que actualizarlas cuando el servidor cambia. Añadir una interacción trivial obliga a tocar cuatro sitios: el tipo de mensaje, la rama de update, el modelo si guarda algo y la vista que lo emite. La interoperación con una librería de JavaScript no es una importación sino un port o un elemento personalizado, con su protocolo y su serialización. Nada de esto es complejidad accidental disfrazada; es trabajo real que en el otro ecosistema no se hace, y quien lo niegue está vendiendo algo.

Lo que la comparación honesta debe añadir es que ese trabajo no se distribuye igual en el tiempo, y que enfrente hay un coste simétrico que rara vez se contabiliza porque no se paga escribiendo sino depurando. En React, decidir cuándo se vuelve a ejecutar un efecto, qué entra en su lista de dependencias, qué se memoriza y qué identidad tiene una función entre renders es un conjunto de reglas que el sistema de tipos no expresa y que se aprenden por accidente. La consecuencia es que el reloj de React marca velocidad muy alta en la primera semana y va acumulando una deuda difusa de estado desincronizado, mientras el reloj de Elm marca lentitud inicial y una pendiente casi plana después, porque el compilador convierte cada cambio estructural en una lista finita de errores que hay que apagar hasta que el programa vuelve a estar completo.

La diferencia se aprecia mejor en un ejercicio concreto que en cualquier argumento general. Piensa en un cambio de dominio banal: un pedido que tenía tres estados pasa a tener cuatro, y el nuevo estado requiere datos que los otros no tienen. En Elm el cambio empieza tocando el tipo, y a partir de ahí el compilador enumera todos los sitios del proyecto donde falta tratar la variante nueva; cuando la lista se vacía, el trabajo está terminado y la afirmación es demostrable, no optimista. En TypeScript el cambio empieza igual, el editor señala varios sitios, y quedan fuera exactamente aquellos que el sistema no gobierna: la respuesta del servidor que traía el estado como texto, la comprobación escrita sin la rama exhaustiva, el componente que recibe la propiedad con una firma más laxa de la cuenta. Ninguno de esos tres se descubre compilando; se descubren probando, o no se descubren.

-- Un cambio de dominio en Elm: se toca el tipo y el compilador hace la lista
type Estado
    = Borrador
    | Enviado Instante
    | Entregado Instante
    | Devuelto Instante Motivo   -- variante nueva

-- Cada case del proyecto que no la trate deja de compilar,
-- incluidos los que estan en modulos que nadie recordaba

Conviene también reconocer dónde la ergonomía de React es sencillamente mejor y no hay compensación. La recarga en caliente que preserva el estado de la aplicación mientras se edita el código es una herramienta cotidiana de enorme valor, y el ecosistema de React la tiene más pulida. Las herramientas de inspección del navegador, los perfiles de rendimiento y la integración con entornos de pruebas visuales están años por delante. Y la sencillez de arrancar un proyecto nuevo, con generadores que producen una base funcional en un minuto, no tiene equivalente. Son ventajas reales de experiencia de desarrollo que conviene poner en la balanza sin rebajarlas, porque un equipo trabaja con esas herramientas todos los días.

ℹ️
Verbosidad no es complejidad

Conviene distinguir dos quejas que suenan igual y no lo son. Un programa de Elm puede necesitar más líneas que su equivalente en React para hacer lo mismo, y sin embargo tener menos conceptos en juego. La verbosidad se paga una vez, al escribir, y se lee sin esfuerzo porque no esconde nada. La complejidad se paga cada vez que alguien intenta entender por qué la pantalla muestra lo que muestra. El lenguaje entero de Elm cabe en una tarde; el conjunto de reglas implícitas que gobiernan un componente de React con hooks, memorización y contexto no cabe en una tarde ni está escrito en un sitio único. Cuando alguien dice que Elm es más verboso, casi siempre tiene razón. Cuando dice que es más complicado, casi siempre está confundiendo lo desconocido con lo difícil.

Lo que hay que conceder sin regatear

Una comparación que no concede nada es propaganda. Hay áreas donde TypeScript con React gana de forma clara y donde insistir sería faltar a la verdad. El ecosistema es la primera y la más obvia: componentes de calendario, editores de texto enriquecido, librerías de gráficos maduras, sistemas de diseño completos, integraciones con proveedores de pago y de autenticación. Todo eso existe, está probado por millones de usuarios y en Elm hay que envolverlo o rehacerlo. La segunda es el renderizado en servidor y todo lo que ha crecido a su alrededor, un terreno donde el ecosistema de React ha invertido años y donde Elm sencillamente no compite. La tercera es la contratación, que merece su propia lección. La cuarta es que TypeScript es estrictamente JavaScript mejorado, de modo que la migración es incremental archivo a archivo y siempre reversible, mientras que adoptar Elm es una decisión de todo o nada por módulo con una frontera que hay que diseñar.

⚠️
La comparación cambia de signo con el horizonte

Una consecuencia práctica de los dos relojes es que la misma comparación, hecha con los mismos datos, arroja resultados opuestos según cuándo se evalúe. A las seis semanas, React gana casi siempre: hay más pantallas terminadas, la integración con lo existente ya funciona y el equipo se mueve con soltura. A los dos años, con el mismo equipo y el mismo producto, la comparación suele invertirse, y no por ninguna virtud mística sino porque el coste que Elm cobró por adelantado ya está pagado y el que React fue difiriendo empieza a vencer. Si tu decisión se evalúa en la primera revisión trimestral, sabes desde hoy qué va a decir esa revisión, y conviene decirlo tú antes que ella.

Hay además un argumento que suele plantearse mal y conviene enderezar. Se dice que TypeScript puede alcanzar garantías equivalentes con librerías de validación en la frontera, tipos discriminados y comprobación exhaustiva mediante el tipo vacío. Es cierto, y un equipo muy disciplinado consigue una aproximación notable. Lo que no consigue es que esa disciplina sea una propiedad del código en vez de una propiedad del equipo. La diferencia se nota el día que entra alguien nuevo, el día que hay una fecha de entrega, o el día que una dependencia cambia sus tipos. En Elm la garantía no depende de que nadie se relaje, porque no hay ninguna forma de relajarse.

Y hay un último punto que suele ganarse por incomparecencia y no debería. Elm no compite con React en el terreno donde React se juzga a sí mismo, que es el del rendimiento del renderizado con listas largas y actualizaciones frecuentes. La comparación aquí es más pareja de lo que ambos bandos suponen: Elm tiene su propia reconciliación de árbol virtual, sus mecanismos de listas con clave y de evaluación diferida, y el tamaño de sus paquetes compilados es habitualmente menor porque el compilador elimina agresivamente lo que no se usa. Nada de eso es decisivo para elegir, y presentarlo como argumento central sería un error: los dos son suficientemente rápidos para casi cualquier interfaz de negocio, y quien elija por milisegundos estará optimizando la variable que menos afecta a su resultado.

flowchart TD
A[Valor externo entra al programa] --> B{Que hace el sistema de tipos}
B -->|TypeScript| C[Se le pone una anotacion]
C --> D[Nadie comprueba en ejecucion]
D --> E[Fallo posible en produccion]
B -->|Elm| F[Se ejecuta un decoder]
F --> G[Result con exito o error]
G --> H[El compilador obliga a tratar ambos]
style E fill:#f38ba8,color:#11111b
style H fill:#a6e3a1,color:#11111b
style B fill:#89b4fa,color:#11111b
Un sistema de tipos solo puede prometer lo que abarca su definición del programa, y ahí termina toda la discusión

La pregunta que ordena esta comparación no es cuál de los dos sistemas de tipos es más expresivo, porque en varios frentes lo es el de TypeScript, con tipos condicionales, plantillas literales y una capacidad de modelar transformaciones que Elm ni intenta. La pregunta es qué conjunto de programas gobierna cada uno. TypeScript gobierna el conjunto de los programas que pasaron por su compilador, y ese conjunto no coincide con el de los programas que se ejecutan: cada dependencia sin tipar, cada respuesta de red, cada aserción escrita a las once de la noche introduce un valor que el sistema jamás vio y sobre el que, sin embargo, todo el resto del código razona como si lo hubiera visto. Un sistema de tipos aplicado a un subconjunto de la ejecución no produce garantías, produce probabilidades muy buenas, y la diferencia entre una garantía y una probabilidad muy buena no se aprecia en el día a día sino en la cola de la distribución, que es exactamente donde viven los incidentes. Elm toma la decisión contraria y paga por ella un precio que esta lección no ha escondido: se lleva el lenguaje entero al lado del compilador, y a cambio de perder el acceso directo a un ecosistema inmenso obtiene que el conjunto de programas gobernados y el conjunto de programas ejecutados sean el mismo conjunto. Lo que hay que entender, y trasciende por completo a ambas tecnologías, es que ninguna cantidad de rigor aplicada dentro de una frontera compensa la existencia de la frontera. Por eso las discusiones sobre configuraciones estrictas, reglas de linter y convenciones de equipo, siendo útiles, nunca cierran el hueco: están mejorando la calidad del interior mientras el problema está en el borde. Elegir tecnología es, antes que cualquier otra cosa, elegir dónde se pone ese borde y quién responde de lo que lo cruza.

⚔️ Reconstruye la comparación con datos, no con anécdotas
  1. Toma un módulo real en TypeScript y cuenta cuántos valores entran en él sin haber sido verificados en ejecución; clasifícalos por la puerta que usaron.
  2. Reescribe la frontera de red de ese módulo con validación en ejecución y mide cuántas líneas cuesta; compáralo honestamente con el decoder equivalente en Elm.
  3. Elige un cambio estructural del dominio, aplícalo en ambas tecnologías y anota cuántos sitios te señaló el compilador y cuántos descubriste probando.
  4. Escribe la lista de dependencias de npm que tu proyecto usa de verdad y marca cuáles tendrían equivalente en Elm, cuáles se envolverían y cuáles bloquearían el proyecto.
  5. Defiende por escrito, con tus datos, la elección de TypeScript con React para tu caso; después defiende la contraria con los mismos datos.
  6. Formula el criterio que separa ambas defensas y comprueba si depende del proyecto, del equipo o del horizonte temporal.