wandres.dev
EL LENGUAJE: TIPOS · estáticos e inferidos

Sin null y sin any: la clase entera de errores que desaparece

Elm no tiene null, ni undefined, ni nil, ni any, ni casting dinámico, ni excepciones lanzadas por el runtime. Esta lección explica qué implica cada una de esas ausencias y por qué juntas producen la garantía por la que el lenguaje es conocido: cero excepciones en tiempo de ejecución. Se parte del error de los mil millones de dólares, la decisión de Tony Hoare de admitir un valor que habita todos los tipos y destruye por tanto toda promesa que un tipo pueda hacer; se muestra la alternativa de Elm, que trata la ausencia como un valor ordinario de un tipo explícito, Maybe, que el compilador obliga a desmontar antes de usar; se examina por qué no existe any ni ninguna puerta trasera que permita mentirle al verificador, y qué significa que operaciones parciales como el acceso a una lista vacía devuelvan Maybe en lugar de reventar. Se cierra midiendo el coste real de la decisión: más ruido local a cambio de la desaparición de una categoría completa de fallos en producción.

⏱ 18 min

Hay dos agujeros por los que se escapa la seguridad de casi todos los sistemas de tipos del mundo, y Elm los tapó los dos. El primero es el valor nulo: un habitante especial que pertenece a todos los tipos a la vez y que, por tanto, convierte cada promesa del compilador en una promesa condicionada, porque donde el tipo dice aquí hay un texto en realidad dice aquí hay un texto o nada. El segundo es la vía de escape del propio verificador, ese tipo comodín llamado any, Object o void* según el idioma, junto con los castings que permiten afirmar sin pruebas que un valor es lo que a ti te conviene. Elm no tiene ninguno de los dos. No hay null, ni undefined, ni nil; no hay any, ni conversión dinámica, ni forma de mentirle al compilador. Y de esa doble ausencia, sostenida sin excepciones ni atajos, sale la afirmación por la que el lenguaje es célebre: los programas Elm no lanzan excepciones en tiempo de ejecución. No porque las capturen mejor, sino porque las situaciones que las producirían no compilan.

🎯 Al terminar esta lección sabrás
  • Comprender por qué un valor nulo que habita todos los tipos destruye las garantías de cualquier sistema de tipos.
  • Representar la ausencia como un valor ordinario mediante Maybe y desmontarlo con case de forma exhaustiva.
  • Explicar por qué la falta de any y de casting impide mentirle al compilador y qué se gana con ello.
  • Evaluar honestamente el coste de la decisión y saber cuándo el ruido local compensa la garantía global.

El valor que habita todos los tipos

Tony Hoare llamó a su propia invención el error de los mil millones de dólares, y la cifra probablemente se quedaba corta. La idea consistía en admitir en el lenguaje un valor especial que fuese miembro legítimo de cualquier tipo. Parecía inofensiva y resultó devastadora, porque anula el significado de las anotaciones. Cuando una firma dice que un parámetro es un texto, en un lenguaje con nulos está diciendo en realidad que es un texto o la nada, y esa disyunción está oculta: no aparece en el tipo, no la ves al leer y el compilador no te obliga a atenderla.

-- En Elm este tipo significa exactamente lo que dice
longitud : String -> Int
longitud texto =
    String.length texto

-- Si un valor puede faltar, el tipo LO DICE, y es otro tipo
type Maybe a
    = Just a
    | Nothing

longitudOpcional : Maybe String -> Int
longitudOpcional posible =
    case posible of
        Just texto ->
            String.length texto

        Nothing ->
            0

La diferencia entre las dos firmas es la diferencia entre un sistema de tipos que garantiza y uno que sugiere. Maybe String y String son tipos distintos, sin relación de subtipado, y no puedes pasar uno donde se espera el otro. Para llegar al texto que hay dentro de un Maybe estás obligado a abrirlo, y abrirlo significa escribir las dos ramas: la que tiene valor y la que no. El compilador comprueba la exhaustividad del case, de modo que olvidar el caso vacío no es un descuido que se descubre en producción, es un programa que no llega a existir.

ℹ️
Maybe no es magia: es un custom type de la biblioteca

Lo notable de Maybe es que no forma parte del núcleo del lenguaje ni recibe trato privilegiado del compilador. Es un tipo unión corriente con dos variantes, definido en la biblioteca estándar, y podrías haberlo escrito tú en dos líneas. Toda su fuerza viene de dos propiedades genéricas del sistema de tipos: que las variantes obligan a distinguir casos y que el case debe ser exhaustivo. Ahí está la elegancia de la solución: no hizo falta añadir una característica al lenguaje para resolver el problema del nulo, bastó con no añadir el nulo.

Ninguna función parcial, ninguna sorpresa

La segunda mitad del asunto es qué ocurre con las operaciones que en otros lenguajes fallan. Pedir el primer elemento de una lista vacía, buscar una clave que no está, convertir a número un texto que no lo es, dividir por cero: son funciones parciales, definidas solo para algunas entradas. La respuesta habitual es lanzar una excepción o devolver un nulo. La respuesta de Elm es hacer que la parcialidad aparezca en el tipo, de modo que la firma advierta de antemano y el compilador exija atender el caso.

-- Toda operacion que puede no dar resultado lo dice en su tipo
-- List.head    : List a -> Maybe a
-- Dict.get     : comparable -> Dict comparable v -> Maybe v
-- String.toInt : String -> Maybe Int
-- Array.get    : Int -> Array a -> Maybe a

primeraEdad : List Usuario -> Int
primeraEdad usuarios =
    usuarios
        |> List.head
        |> Maybe.map .edad
        |> Maybe.withDefault 0

Encadenar así es el patrón cotidiano. Maybe.map aplica una función solo si hay valor y no hace nada si no lo hay; Maybe.withDefault cierra la cadena aportando un valor de respaldo; Maybe.andThen encadena operaciones que a su vez pueden fallar. Ninguna de esas funciones evita la decisión: te obligan a tomarla, pero te dejan tomarla en el punto que tú elijas, que suele ser el borde del sistema y no el fondo.

flowchart TD
A[Operacion que puede fallar] --> M[Maybe a]
M -->|Just| V[Valor disponible]
M -->|Nothing| N[Ausencia explicita]
V --> D[case exhaustivo obligatorio]
N --> D
D --> R[Programa sin excepciones]
style D fill:#89b4fa,color:#11111b
style R fill:#a6e3a1,color:#11111b

Sin any: no hay puerta trasera

La ausencia de nulos sería insuficiente si quedara abierta una vía para eludir al verificador, y aquí Elm es todavía más estricto. No existe un tipo comodín que acepte cualquier cosa. No existe la conversión forzada que afirma sin pruebas que un valor es de cierto tipo. No existe la reflexión en tiempo de ejecución que permita preguntar por el tipo real de algo y actuar en consecuencia. Todo dato que entra desde el exterior —una respuesta JSON, un valor empujado por un puerto, un parámetro de la URL— debe atravesar un decodificador que lo valida y produce un Result con el dato bien tipado o con un error descrito. No hay forma de saltarse ese trámite, ni siquiera prometiendo que esta vez el servidor sí devolverá lo esperado.

Cuando el fallo necesita explicarse y no basta con decir que no hay valor, Elm ofrece el hermano informativo de Maybe. Un Result tiene también dos variantes, pero la de fallo lleva carga: dice qué salió mal. Es el tipo que devuelven los decodificadores y el que viaja en las respuestas de red, y su tratamiento es idéntico, un case exhaustivo sobre las dos posibilidades.

-- La ausencia no informa; el fallo si
type Result error valor
    = Ok valor
    | Err error

-- Un decodificador es la unica aduana por la que entra el exterior
leerEdad : String -> String
leerEdad crudo =
    case Decode.decodeString (Decode.field "edad" Decode.int) crudo of
        Ok edad ->
            "Edad: " ++ String.fromInt edad

        Err _ ->
            "JSON invalido"
🕳️

Sin null

Ningún valor habita todos los tipos. Una firma dice exactamente lo que dice, y la ausencia, cuando existe, aparece escrita en el tipo.

🚧

Sin any

No hay comodín ni casting. El compilador no admite afirmaciones sin prueba, así que su verificación cubre el programa entero y no solo las partes anotadas con honestidad.

🛃

Frontera con decodificadores

Los datos externos entran validados o no entran. El JSON crudo nunca se convierte en un valor tipado por decreto, solo tras pasar un decodificador que puede fallar.

🧯

Sin excepciones

No hay lanzar ni capturar. El fallo esperable viaja en Result y la ausencia en Maybe, ambos como valores que el flujo normal del programa debe atender.

La comparación con TypeScript ilumina el punto. TypeScript tiene un sistema de tipos expresivo y unos tipos opcionales bien diseñados, pero conserva any, conserva las aserciones de tipo y descansa sobre un runtime que sí tiene nulos y excepciones. El resultado es que sus garantías son locales y revocables: valen mientras nadie escriba una aserción de más, y se evaporan justo en las fronteras, que es donde más falta hacen. Elm renuncia a expresividad —no tiene ni de lejos la potencia de tipos de TypeScript— a cambio de que lo poco que promete lo cumpla siempre y en todo el programa. Es la diferencia entre un sistema que ayuda y un sistema en el que se puede confiar.

⚠️
La garantía es sobre tu código, no sobre el universo

Cero excepciones en tiempo de ejecución significa que tu código Elm no aborta por un tipo incorrecto, un nulo o un caso no contemplado. No significa que la aplicación no pueda fallar por otras vías: un bucle infinito sigue colgando, un servidor caído sigue sin responder, la memoria sigue siendo finita y el código JavaScript al otro lado de un puerto puede hacer lo que quiera. La promesa es acotada y precisa, y ese es justamente su valor: una garantía honesta y verificable vale más que una tranquilidad difusa.

El coste, dicho sin adornos

Conviene admitir la contrapartida sin maquillarla. Trabajar sin nulos obliga a escribir código que en otros lenguajes no escribirías: cada Maybe hay que abrirlo, cada camino sin valor hay que decidirlo, y si un modelo mal diseñado esparce opcionales por todas partes, el resultado son cadenas de desempaquetado que estorban a la lectura. Quien llega desde un lenguaje permisivo percibe eso como burocracia, y en cierto sentido lo es. La respuesta madura no consiste en negar la molestia sino en localizar su causa: casi siempre, un exceso de Maybe es un síntoma de que el modelo admite estados que en realidad no existen, y la cura no es tolerar el ruido sino rediseñar el tipo para que el caso imposible desaparezca.

-- Sintoma: tres campos opcionales que se coordinan a mano
type alias ModeloDebil =
    { cargando : Bool, datos : Maybe (List Item), error : Maybe String }

-- Cura: un custom type donde los estados imposibles no existen
type Estado
    = Inactivo
    | Cargando
    | Listo (List Item)
    | Fallo String

El primer modelo permite cuatro combinaciones sin sentido, entre ellas estar cargando con error y datos a la vez. El segundo no las permite en absoluto, y de paso elimina dos Maybe del código. Esa es la técnica que hace agradable la vida sin nulos, y la desarrollaremos a fondo cuando lleguemos a los custom types y al arte de hacer que los estados imposibles no compilen.

Quitar un valor del lenguaje elimina más errores que añadir cien comprobaciones

El fondo del asunto no es que Maybe sea un buen sustituto de null, sino algo más radical sobre cómo se consigue la fiabilidad. Frente a una clase de errores, la reacción instintiva de la ingeniería es añadir: añadir comprobaciones, añadir análisis estáticos, añadir convenciones, añadir avisos del linter, añadir pruebas que verifiquen que nadie olvidó comprobar. Toda esa maquinaria intenta detectar un problema que sigue siendo posible. Elm siguió el camino contrario, que es el de la sustracción: retiró del lenguaje el valor que hacía posible el error, y con él desapareció la necesidad de detectarlo. No hay un analizador que avise de posibles nulos porque no hay nulos que analizar; no hay una regla que exija comprobar antes de usar porque no existe la forma sintáctica de usar sin comprobar. La categoría entera de fallo se ha vuelto inexpresable, y lo inexpresable no necesita vigilancia. Este principio —eliminar la posibilidad en lugar de vigilar la ocurrencia— es la idea de diseño más transferible de todo el lenguaje, y funciona muy lejos de Elm: cuando una clase de bug reaparece una y otra vez en un sistema, la pregunta productiva no es qué comprobación añadimos para cazarla, sino qué capacidad estamos concediendo que la hace posible y qué costaría retirarla. La respuesta suele ser incómoda, porque esa capacidad casi siempre resulta cómoda en el corto plazo: el nulo ahorra pensar en el caso vacío, el comodín ahorra pensar en la frontera, la excepción ahorra pensar en el fallo. Elm sostiene que ese ahorro se paga con intereses, y su historial de aplicaciones que llevan años sin un solo error en producción es la evidencia más citada a favor. La seguridad no vino de un compilador más listo, sino de un lenguaje con menos cosas dentro.

⚔️ Vive sin nulos y comprueba qué desaparece
  1. Escribe una función que reciba una List Int y devuelva su primer elemento, y observa que la firma honesta es List Int -> Maybe Int.
  2. Desmóntala con case cubriendo las dos variantes; borra después una rama y estudia el error de exhaustividad del compilador.
  3. Reescribe la misma lógica en cadena con Maybe.map y Maybe.withDefault y compara cuál de las dos versiones se lee mejor.
  4. Encadena dos operaciones que pueden fallar usando Maybe.andThen y explica por qué no basta con Maybe.map.
  5. Toma un modelo con tres campos opcionales y sustitúyelo por un custom type de estados; cuenta cuántas combinaciones imposibles has eliminado.
  6. Enumera tres errores frecuentes de tu lenguaje habitual y decide, para cada uno, si Elm los previene, los transforma en otra cosa o no los toca en absoluto.