wandres.dev
NIVEL DIOS: SÍNTESIS · pensar funcionalmente

Pensar funcionalmente en cualquier lenguaje: modelar con tipos y tratar los efectos como datos

El valor duradero de haber aprendido Elm no es poder escribir Elm, porque la mayoría de quienes lo estudian acabarán trabajando en otro sitio. El valor duradero son dos hábitos mentales que sobreviven al cambio de lenguaje y que transforman el código que se escribe en TypeScript, Kotlin, Swift, Rust, Python o Java. El primero es modelar el dominio haciendo imposibles los estados inválidos, lo cual exige traducir la unión etiquetada a lenguajes que la tienen con otro nombre, que la tienen a medias o que no la tienen en absoluto. El segundo es tratar los efectos como valores, empujando toda la ejecución hacia un borde delgado y dejando un núcleo puro que se puede probar sin dobles ni infraestructura. Esta lección recorre esas dos traducciones con honestidad sobre lo que se pierde en cada una, explica qué garantías dejan de ser automáticas y pasan a depender de la disciplina del equipo, y ofrece criterios para decidir hasta dónde llevar el estilo antes de que deje de compensar.

⏱ 23 min

Conviene decirlo sin rodeos: la mayoría de quienes aprenden Elm no van a cobrar por escribir Elm. Eso no convierte el esfuerzo en un ejercicio estético, porque lo que se ha aprendido no son las funciones de una biblioteca sino dos hábitos que reorganizan la forma de escribir cualquier programa. El primero consiste en dejar de representar el dominio con estructuras permisivas y empezar a representarlo con tipos que hagan imposibles los estados que no existen. El segundo consiste en dejar de disparar efectos donde se decide que hay que dispararlos, y en su lugar devolver una descripción del efecto para que alguien más arriba lo ejecute. Ninguno de los dos necesita un compilador de Elm; los dos necesitan a alguien que sepa por qué importan, y esa persona ahora eres tú. Lo que sí cambia radicalmente al salir de Elm es el estatuto de la garantía: aquí el compilador prohibía, allí el equipo acuerda. La diferencia es real y esta lección no la va a maquillar, porque saber exactamente qué protección se pierde es la condición para compensarla en el sitio correcto.

🎯 Al terminar esta lección sabrás
  • Traducir la unión etiquetada a lenguajes que la ofrecen con otro nombre, que la ofrecen parcialmente o que carecen de ella, sabiendo qué garantía se conserva en cada caso.
  • Aplicar el criterio de estados inválidos imposibles como técnica de diseño independiente del lenguaje, y medir su efecto en el número de combinaciones representables.
  • Reproducir el patrón de efectos como datos con un núcleo puro y una cáscara ejecutora, y explicar por qué eso hace innecesarios los dobles de prueba.
  • Decidir con criterio hasta dónde llevar el estilo funcional en un código base ajeno, evitando tanto la resignación como el celo contraproducente.

Traducir la unión etiquetada

La pieza que más echarás de menos es el tipo suma, y lo primero que hay que saber es que existe en más sitios de los que parece, solo que con otros nombres y con distinto grado de comodidad. Rust lo tiene completo y con coincidencia exhaustiva, hasta el punto de que su enum con datos y su match son el equivalente exacto de la unión etiquetada y el case of. Swift lo tiene con valores asociados y comprobación de exhaustividad. Kotlin lo tiene mediante clases selladas, con exhaustividad garantizada cuando la expresión se usa como valor. TypeScript lo tiene mediante uniones discriminadas, con estrechamiento de tipos por un campo etiqueta, y la exhaustividad se obtiene con un truco deliberado: una rama por defecto que asigna el valor restante al tipo vacío, de forma que añadir un caso nuevo rompe la compilación exactamente igual que en Elm. Java lo tiene desde que existen las interfaces selladas y la coincidencia de patrones. Python lo aproxima con clases de datos y una estructura de coincidencia estructural, sin exhaustividad real salvo que se emplee un comprobador estático configurado para exigirla.

-- El original en Elm: cuatro estados y ni uno mas.

type Peticion e a
    = Ociosa
    | Cargando
    | Fallida e
    | Lista a


ver : Peticion Error Informe -> Html Msg
ver peticion =
    case peticion of
        Ociosa -> vacio
        Cargando -> hilandero
        Fallida e -> aviso e
        Lista informe -> tabla informe

La traducción conserva la forma en todos esos lenguajes: un tipo con una etiqueta obligatoria, tantos casos como situaciones reales, y datos que solo existen dentro del caso al que pertenecen. Lo que cambia entre unos y otros es cuánto trabajo hay que hacer para que el compilador avise al añadir un caso nuevo, y esa es la propiedad que hay que defender por encima de la elegancia sintáctica, porque es la que convierte una ampliación del dominio en una lista de deberes en vez de en una cacería de fallos en producción.

Hay una prueba de laboratorio que conviene ejecutar la primera vez que se hace la traducción en un lenguaje nuevo, y que revela en dos minutos si la garantía existe de verdad o solo se ha imitado su aspecto. Consiste en añadir un quinto caso al tipo y compilar sin tocar nada más. Si el compilador señala todos los puntos que deben atenderlo, la traducción es fiel y puedes confiar en ella para modelar el dominio entero. Si compila en silencio, lo que tienes es documentación con sintaxis de tipos, y el equipo descubrirá el caso faltante cuando un usuario lo encuentre. Entre ambos extremos está el caso intermedio de los lenguajes donde la exhaustividad se obtiene solo bajo ciertas construcciones, y ahí la disciplina consiste en usar siempre esa construcción aunque resulte más verbosa.

-- El invariante que hay que preservar al traducir, sea cual sea la sintaxis:
--
--   1. La etiqueta es obligatoria y no puede faltar.
--   2. Los datos de un caso no existen fuera de ese caso.
--   3. Nadie puede fabricar un caso saltandose la validacion.
--   4. Anadir un caso rompe la compilacion en todos los consumidores.
--
-- Si los cuatro se cumplen, tienes una union etiquetada de verdad.
-- Si falta el cuarto, tienes una convencion. Si falta el tercero,
-- tienes una convencion peligrosa, porque induce confianza sin darla.
⚠️
La construcción también tiene que estar cerrada

En los lenguajes con clases el peligro no está en la coincidencia sino en la construcción. Si cualquiera puede instanciar el caso Lista con un informe nulo, o crear un objeto que tenga a la vez datos de éxito y de error, la unión no protege nada: se ha reproducido la sintaxis sin reproducir la garantía. La contramedida es hacer privados los constructores y exponer únicamente funciones de fabricación que validen, que es exactamente lo que en Elm consigues gratis al no exponer los constructores de un tipo en el módulo. Sin ese cierre, el tipo suma es decorativo, y peor que decorativo, porque induce una confianza que no está respaldada.

Los efectos como datos fuera de Elm

El segundo hábito es más portátil de lo que suele creerse y no exige ninguna biblioteca. La forma mínima consiste en que la función que decide devuelva, junto al estado nuevo, una lista de descripciones de lo que hay que hacer, y que un ejecutor situado en el borde recorra esa lista y la haga ocurrir. Ese ejecutor es el único punto del programa que conoce el cliente de red, el reloj, el generador aleatorio o el sistema de ficheros. Todo lo demás queda puro, y lo que se gana no es pureza por deporte: se gana la capacidad de probar la lógica comparando valores, sin dobles, sin inyección de dependencias y sin esperar a que un temporizador expire.

-- La forma general, independiente del lenguaje:
--   decidir : Suceso -> Estado -> ( Estado, List Efecto )
--
-- Efecto es un tipo de datos, no una llamada.

type Efecto
    = PedirInforme IdInforme
    | GuardarEnDisco Ruta Contenido
    | Registrar Nivel String
    | Programar Milisegundos Suceso


decidir : Suceso -> Estado -> ( Estado, List Efecto )
decidir suceso estado =
    case suceso of
        PidioInforme id ->
            ( { estado | informe = Cargando }
            , [ PedirInforme id, Registrar Info "peticion iniciada" ]
            )

        LlegoInforme (Ok informe) ->
            ( { estado | informe = Lista informe }, [] )

        LlegoInforme (Err e) ->
            ( { estado | informe = Fallida e }
            , [ Registrar Aviso "fallo la peticion", Programar 3000 Reintentar ]
            )

Traducido a un lenguaje imperativo, decidir es una función estática sin dependencias que recibe dos argumentos y devuelve una tupla o un pequeño registro. El ejecutor es un bucle que consume la lista y llama al mundo real. Una prueba de la lógica se reduce a invocar decidir y comparar el resultado con el esperado, incluida la lista de efectos: se comprueba que se pidió el informe sin haber pedido nada, y que se programó un reintento sin haber esperado tres segundos. Esa es la ganancia práctica que convence a un equipo escéptico mucho antes que cualquier argumento sobre pureza.

Merece la pena insistir en un punto que suele malinterpretarse. Nadie propone que un programa entero sea puro, porque un programa entero puro no hace nada observable y por tanto no sirve para nada. La propuesta es geométrica y no moral: que la parte impura sea delgada, esté en el borde y se pueda señalar con el dedo. Un sistema con un núcleo puro grande y una cáscara impura fina se prueba con comparaciones de valores, se razona leyendo una función y se modifica sin miedo. Un sistema donde el efecto está repartido uniformemente por todas las capas necesita infraestructura para probar cualquier cosa, y esa necesidad es la que produce las suites de pruebas lentas y frágiles que después nadie ejecuta. La diferencia entre ambos no es de estilo, es de coste de mantenimiento medido en horas.

-- Beneficio directo: una prueba sin dobles, sin red y sin esperas.

test "un fallo programa un reintento y deja constancia" <|
    \_ ->
        decidir (LlegoInforme (Err TiempoAgotado)) estadoCargando
            |> Expect.equal
                ( { estadoCargando | informe = Fallida TiempoAgotado }
                , [ Registrar Aviso "fallo la peticion"
                  , Programar 3000 Reintentar
                  ]
                )

-- La lista de efectos es un valor comparable. Eso es todo el truco,
-- y funciona igual en cualquier lenguaje con igualdad estructural.
flowchart LR
A[Suceso del mundo] --> B[Nucleo puro decidir]
B --> C[Estado nuevo]
B --> D[Lista de efectos como datos]
D --> E[Cascara ejecutora en el borde]
E --> F[Red, disco, reloj, aleatorio]
F --> A
C --> G[Vista o salida]
style B fill:#a6e3a1,color:#11111b
style D fill:#f9e2af,color:#11111b
style E fill:#f38ba8,color:#11111b

Qué se pierde en la traducción

Aquí es donde la honestidad importa más que el entusiasmo, porque quien exporta el estilo sin conocer sus grietas acaba con lo peor de los dos mundos. Se pierde, en primer lugar, la garantía de que nadie salte la valla. En Elm es imposible que una función escondida en la profundidad de la vista dispare una petición; en TypeScript o en Kotlin es perfectamente posible, y lo será mientras alguien tenga prisa. Se pierde también la exhaustividad automática allí donde el lenguaje no la impone, y con ella la propiedad más valiosa del sistema: que ampliar el dominio produzca una lista de errores de compilación en vez de un comportamiento silenciosamente incompleto. Se pierde la ausencia de nulos, que reintroduce en cada frontera la duda sobre si un valor está o no está. Y se pierde la inmutabilidad por defecto, de modo que un estado que se creía nuevo puede compartir referencias con el viejo y producir una mutación a distancia difícil de rastrear.

💡
Compensaciones que sí funcionan en la práctica

Cada pérdida tiene un contrapeso concreto y barato. Para la valla, una regla de linter que prohíba importar el cliente de red fuera del directorio del borde, aplicada en integración continua. Para la exhaustividad, el patrón de comprobación con el tipo vacío en TypeScript o el uso de la expresión como valor en Kotlin, que fuerza el chequeo. Para los nulos, activar el modo estricto del comprobador y prohibir la aserción de no nulidad salvo con justificación escrita. Para la inmutabilidad, tipos de solo lectura, estructuras congeladas en desarrollo y una prohibición explícita de mutar argumentos. Ninguna de estas medidas es tan fuerte como la garantía original, y todas juntas capturan la mayor parte del beneficio a un coste que un equipo acepta sin discusión.

Hasta dónde llevarlo

Hay además una pérdida de otro orden, más difícil de nombrar y que conviene anticipar porque desanima. En Elm el lenguaje empuja a todo el equipo en la misma dirección, de modo que el código de un compañero que nunca ha oído hablar de programación funcional se parece bastante al tuyo. Fuera de Elm eso desaparece: el estilo pasa a ser una elección personal, y un código base con dos filosofías conviviendo puede resultar más confuso que uno con una sola filosofía mediocre aplicada con coherencia. Por eso la exportación de estos hábitos es tanto un problema social como técnico, y por eso funciona mejor cuando empieza por un módulo entero convertido con convicción que por una siembra de buenas prácticas repartidas por todas partes.

El error más frecuente de quien vuelve de Elm no es no aplicar nada, es aplicarlo todo. Reescribir un código base ajeno en estilo funcional durante seis meses produce un sistema híbrido que nadie entiende, resistencia del equipo y la conclusión errónea de que el estilo no funciona. El camino que sí funciona es quirúrgico y empieza por el sitio donde el beneficio es más visible para quien no comparte tus convicciones: coge el módulo con más incidencias del último trimestre, extrae su lógica de decisión a una función pura con efectos como datos, escribe las pruebas que antes eran imposibles y enseña el resultado. La segunda intervención suele pedirla otra persona. Después vienen los tipos: sustituir el registro con cinco campos opcionales por una unión de tres casos es un cambio local, medible y difícil de rebatir, porque el número de combinaciones representables baja de treinta y dos a tres y eso se puede escribir en la descripción del cambio.

Lo que de verdad te llevas no es un estilo, es una pregunta que puedes hacerle a cualquier programa

Al cabo de un tiempo trabajando fuera de Elm te darás cuenta de que lo que quedó no es un repertorio de técnicas sino una pregunta, y que esa pregunta funciona como instrumento de diagnóstico sobre cualquier código base, escrito en cualquier lenguaje y por cualquier equipo. La pregunta es: en este programa, qué cosas pueden estar representadas y no deberían poder estarlo, y en qué punto exacto una decisión se convierte en un acto. La primera mitad detecta la enfermedad del modelado. Cuando encuentres una clase con doce campos opcionales, no verás una clase grande; verás cuatro mil noventa y seis combinaciones posibles, de las cuales quizá seis corresponden a algo real, y sabrás que las cuatro mil noventa restantes están siendo defendidas a mano con condicionales dispersos por veinte ficheros, y que cada nuevo requisito añade defensas y multiplica el espacio. La segunda mitad detecta la enfermedad de los efectos. Cuando encuentres una función llamada desde un manejador de eventos que además escribe en la base de datos, envía un correo y publica una métrica, no verás una función que hace tres cosas; verás que no existe ningún punto del programa donde alguien pueda leer la intención antes de que ocurra, y por tanto que no hay forma de probarla, de reintentarla, de registrarla ni de simularla sin levantar medio sistema. Ese par de miradas vale más que cualquier lenguaje concreto, porque no depende de que tu equipo adopte nada: puedes ejercerlas en la primera hora de un proyecto ajeno y salir con un diagnóstico que la mayoría de tus compañeros tardará meses en formular. Y hay un corolario incómodo que conviene aceptar pronto: casi nunca el problema de un sistema difícil es que le falte una abstracción. Casi siempre es que le sobran estados y le faltan fronteras. Elm te enseñó a ver ambas cosas de golpe, y esa visión no caduca cuando cierras el compilador.

⚔️ Exporta los dos hábitos a un código base que no sea tuyo
  1. Toma una clase o un registro real de tu trabajo con varios campos opcionales, calcula cuántos estados admite y cuántos corresponden a algo del mundo. Escribe la diferencia como un número.
  2. Redisénalo como unión etiquetada en el lenguaje que uses a diario y asegúrate de que los constructores estén cerrados, de modo que nadie pueda fabricar un caso inválido desde fuera del módulo.
  3. Añade la comprobación de exhaustividad del lenguaje y verifica que introducir un caso nuevo produce un error de compilación en todos los puntos que deben atenderlo. Si no lo produce, tu unión aún no protege nada.
  4. Elige la función más difícil de probar de tu código base y pártela en un núcleo que devuelve estado y efectos como datos, más una cáscara que los ejecuta. Escribe una prueba que antes requería un doble y ahora no.
  5. Enumera con precisión las garantías que has perdido respecto a Elm y escribe para cada una la compensación concreta que vas a introducir, con el mecanismo que la hará cumplir sin depender de la memoria de nadie.
  6. Argumenta en contra: describe un caso real de tu trabajo donde este estilo habría sido un coste sin retorno, y sé específico sobre qué lo hizo así.