Por qué un decodificador: el JSON de fuera no es un tipo
Todo programa con tipos tiene un perímetro, y el perímetro está allí donde entran datos que nadie de dentro construyó. El JSON que llega de una red es una cadena sin garantías: puede faltar un campo, puede venir un número donde esperabas texto, puede haber cambiado el servidor esta mañana sin avisar a nadie. La mayoría de los lenguajes tipados cruzan esa frontera con una afirmación no comprobada, sea una aserción de tipo, sea una deserialización reflexiva, sea un generador que confía en el esquema, y con ello desactivan el compilador exactamente en el punto donde más falta hacía. Esta lección explica por qué Elm se niega a esa afirmación y qué pone en su lugar: un valor de tipo Decoder que no lee nada por sí mismo, sino que describe cómo se construiría un valor de tus tipos a partir de algo desconocido, y que al ejecutarse devuelve un Result con el dato limpio o con una explicación precisa de dónde y por qué no encajó.
Un programa tipado es una fortaleza con una puerta, y la puerta está siempre en el mismo sitio: allí donde entran datos que nadie de dentro fabricó. Un valor construido por tu propio código lleva su tipo consigo por construcción, porque el compilador estuvo presente en el acto de crearlo y no admitió otra cosa. Un texto JSON que llega por la red no lleva nada de eso. Es una secuencia de caracteres que alguien, en otra máquina, en otro lenguaje, con otro calendario de despliegues y otras prisas, decidió emitir con una forma determinada. Puede faltar un campo que ayer estaba, puede llegar un número donde tu tipo espera una cadena, puede haber un valor nulo donde tu modelo no contempla ausencia. La pregunta interesante no es si eso ocurrirá, porque ocurrirá, sino qué hace tu lenguaje en el momento exacto en que ocurre. Casi todos responden con una afirmación no comprobada: declaran el tipo de lo recibido y siguen adelante como si nada. Elm responde con otra cosa. No existe manera de afirmar que ese valor desconocido es un Usuario; solo existe manera de describir cómo se construiría un Usuario a partir de él, y de admitir por escrito que a veces no se podrá.
- Situar con precisión la frontera del programa y explicar por qué el tipado se acaba exactamente ahí.
- Leer
Decoder acomo la descripción de una construcción y no como un lector que recorre datos. - Distinguir una aserción de tipo, que no comprueba nada, de una decodificación, que comprueba todo.
- Interpretar el
Resultque devuelvedecodeStringcomo el único puente legítimo entre el exterior y tus tipos.
El perímetro donde el tipado se acaba
Conviene mirar el problema con crudeza antes de mirar la solución. El servidor envía un documento y tu aplicación tiene un tipo que describe lo que espera. Entre ambos no hay ninguna relación garantizada por nadie: son dos afirmaciones independientes sobre la misma forma, escritas en momentos distintos, por equipos distintos, sin un mecanismo que las obligue a coincidir. El esquema puede estar documentado, puede haber un contrato, puede existir un generador de tipos, pero nada de eso interviene en tiempo de ejecución cuando el documento concreto llega.
{
"id": 42,
"nombre": "Ada",
"correo": null,
"etiquetas": ["fundadora", "analitica"]
}
Fíjate en cuántas suposiciones hacen falta para tratar esto como un valor de tu dominio. Que exista un campo con nombre id. Que su contenido sea un número y no la cadena que representa ese número. Que nombre no sea nulo. Que correo admita ausencia y que tu tipo lo refleje. Que etiquetas sea una lista homogénea de cadenas y no una mezcla. Ninguna de esas suposiciones está comprobada por el hecho de escribir un tipo en tu programa: escribir un tipo describe lo que quieres, no lo que llegó. La brecha entre ambas cosas es el lugar donde viven la mayoría de los fallos de producción de las aplicaciones web modernas.
La frontera es física
Red, almacenamiento local, argumentos del arranque, interoperación con JavaScript. Cuatro puertas, un solo problema: datos que el compilador no vio nacer.
Afirmar no es comprobar
Una aserción de tipo es una promesa del programador al compilador. Si la promesa es falsa, el compilador ya se calló y el fallo aparece lejos del origen.
Decoder es una receta
Decoder a no contiene datos ni los recorre por su cuenta: describe cómo obtener una a de un valor desconocido, o por qué no se pudo.
El fallo es local
Si el documento no encaja, el error se produce en la puerta y señala la ruta exacta del campo culpable, no tres capas más adentro.
Un decodificador describe, no lee
El paso conceptual que hay que dar es el mismo que en el nivel de los efectos: separar la descripción de la ejecución. Un Decoder Usuario no es una función que ya haya mirado ningún documento; es un valor que representa un plan de construcción. Puedes nombrarlo, guardarlo en una lista, pasarlo como argumento, combinarlo con otros, devolverlo desde un case, y todo eso sin que ningún dato se haya tocado todavía. La ejecución llega después y en un solo punto, cuando entregas ese plan y un documento concreto a una función que los enfrenta.
-- El plan no ejecuta nada por si mismo
usuarioDecoder : Decoder Usuario
-- Ejecutar el plan sobre un texto o sobre un valor opaco
decodeString : Decoder a -> String -> Result Error a
decodeValue : Decoder a -> Value -> Result Error a
-- Y el error sabe explicarse
errorToString : Error -> String
Hay dos funciones de ejecución y no una porque hay dos clases de entrada. Cuando el dato llega como texto, por ejemplo el cuerpo de una respuesta, se usa la primera. Cuando llega ya interpretado por el entorno anfitrión y viaja como un valor opaco que Elm no puede inspeccionar, como ocurre con los puertos o con los indicadores del arranque, se usa la segunda. La diferencia es de origen, no de naturaleza: en ambos casos el dato es igual de sospechoso y en ambos casos el veredicto tiene la misma forma.
Lee las dos firmas centrales con calma, porque contienen toda la tesis del nivel. Reciben un plan y un dato sin garantías, y devuelven un Result. No devuelven el valor esperado: devuelven la disyunción honesta entre el valor esperado y una explicación del desencuentro. Esa disyunción no se puede saltar, porque el sistema de tipos no ofrece ninguna vía para tratar un Result como si fuera el valor de dentro. Quien decodifica está obligado, en el mismo acto, a decidir qué hace su aplicación cuando el mundo exterior no coopera.
Merece la pena recorrer las alternativas porque todas comparten un rasgo. En JavaScript, la función que interpreta JSON devuelve un valor sin forma declarada y el programa se apoya en la esperanza. En TypeScript, ese valor recibe habitualmente una aserción o una interfaz, que son afirmaciones borradas antes de ejecutar: el tipo existe durante la compilación y desaparece justo cuando haría falta. En Java o en C#, la deserialización reflexiva rellena campos mirando la clase, y el desajuste se manifiesta como un nulo inesperado mucho más tarde. En Swift, el protocolo de decodificación sí comprueba y sí puede lanzar, lo que ya es una diferencia notable de honradez, aunque el mecanismo de fallo siga siendo un canal aparte. En todos los casos, salvo el último a medias, la comprobación se ha delegado en una convención en lugar de en el compilador, y la convención se rompe el día que otro equipo despliega un cambio.
Las cuatro puertas de una aplicación
Conviene inventariar la frontera con nombres concretos, porque en la práctica no es una sino cuatro, y tres de ellas suelen olvidarse. La primera y más evidente es la red, donde el cuerpo de una respuesta llega como texto. La segunda es el almacenamiento del navegador, que devuelve documentos escritos por versiones anteriores de tu propio programa y que por tanto son tan ajenos como los de un tercero. La tercera son los indicadores del arranque, esos valores que la página anfitriona inyecta al iniciar la aplicación. Y la cuarta son los puertos hacia JavaScript, por donde entra cualquier cosa que el ecosistema exterior quiera mandar.
-- Las cuatro puertas devuelven lo mismo: algo sin garantias
decodeString : Decoder a -> String -> Result Error a
-- Y el resultado se examina siempre, sin excepciones
leerIndicadores : Value -> Configuracion
leerIndicadores crudos =
case decodeValue configuracionDecoder crudos of
Ok configuracion ->
configuracion
Err _ ->
configuracionPorDefecto
La uniformidad es la propiedad valiosa: las cuatro puertas se cruzan con el mismo mecanismo, se documentan con el mismo vocabulario y fallan de la misma manera. No hay una capa de red con sus convenciones, otra de persistencia con las suyas y otra de interoperación con las de más allá. Hay decodificadores, que son valores, y un puñado de funciones que los ejecutan. Ese ahorro conceptual explica buena parte de por qué las aplicaciones en Elm envejecen mejor de lo que su tamaño haría esperar.
Fallar en la puerta y no en el centro
El beneficio decisivo de decodificar no es evitar los fallos, que no se evitan, sino concentrarlos. Si la única entrada al dominio pasa por un decodificador, entonces todo valor que circula dentro del programa ya fue comprobado, y el resto del código puede escribirse sin una sola verificación defensiva. No hay que preguntar si el nombre existe, ni si la lista es una lista, ni si el identificador es un número, porque el tipo ya lo afirma y el tipo se ganó en la frontera. Los lenguajes que afirman sin comprobar consiguen lo contrario: como la falsedad puede aparecer en cualquier punto, la desconfianza se reparte por todo el programa en forma de comprobaciones repetidas que nunca son exhaustivas.
-- La puerta unica: fuera hay texto, dentro hay tipos
type Msg
= Recibido (Result Http.Error Usuario)
-- Y dentro del dominio, ninguna comprobacion defensiva
saludar : Usuario -> String
saludar usuario =
"Hola, " ++ usuario.nombre
Hay además un beneficio de diseño que suele descubrirse tarde y conviene anticipar. Como el decodificador es el único paso obligatorio entre el exterior y el dominio, se convierte de manera natural en el sitio donde se traduce, y traducir no es lo mismo que copiar. Puedes renombrar claves que llegan con la nomenclatura de otro sistema, aplanar un objeto anidado que a tu modelo no le aporta jerarquía, convertir una pareja de campos booleanos en una unión etiquetada que haga imposibles los estados absurdos, o descartar por completo la mitad del documento porque tu pantalla no la necesita. El modelo interno deja de ser rehén de la forma del servidor y pasa a ser lo que el dominio pide.
La función que saluda no comprueba nada porque no tiene nada que comprobar: recibe un Usuario, y en este programa un Usuario solo puede existir si alguien lo construyó, sea a mano, sea a través de un decodificador que ya se enfrentó al documento. Esa es la propiedad que explica por qué las aplicaciones en Elm no acumulan condicionales de protección: la confianza interior es consecuencia directa de la desconfianza en el perímetro.
flowchart LR S[Servidor externo] --> T[Texto JSON sin garantias] T --> D[Decoder como plan] D --> R[Result con dato o con causa] R --> M[Mensaje hacia update] M --> Dom[Dominio con tipos de confianza] style D fill:#89b4fa,color:#11111b style R fill:#f9e2af,color:#11111b style Dom fill:#a6e3a1,color:#11111b
Hay una confusión persistente en la cultura de la programación tipada que este nivel deshace de raíz, y consiste en creer que declarar un tipo es una forma de saber algo sobre los datos. No lo es. Declarar un tipo dentro de tu programa es una forma de saber algo sobre tu programa, que es cosa muy distinta. El compilador puede garantizar que ninguna función tuya construye un Usuario con el nombre ausente, y esa garantía es sólida y valiosa; lo que no puede garantizar bajo ningún concepto es que un documento producido en otra máquina, por un equipo que no lee tu código, tenga la forma que tú escribiste. La distancia entre esas dos cosas es precisamente la que la mayoría de los ecosistemas cruzan con una afirmación gratuita, y el nombre técnico de una afirmación gratuita es supuesto. El supuesto no falla al declararlo, falla mucho después, en un punto del programa alejado del origen, cuando un campo que se creía presente resulta ausente y el error hereda una traza que no menciona la petición que lo causó. La decodificación explícita reordena esa economía por completo. Convierte el supuesto en una obligación de prueba y la sitúa en el único sitio donde puede saldarse, que es el momento en que el dato entra. Se paga un precio real y conviene no disimularlo: hay que escribir el decodificador, mantenerlo cuando el esquema cambia, y aceptar que dos descripciones de la misma forma conviven en el proyecto. A cambio se compra algo que ningún generador automático regala, y es que el desajuste entre el mundo y el modelo deje de ser un fallo tardío e inexplicable para convertirse en un valor que tu programa recibe, examina y muestra. Cuando el fracaso de la frontera es un dato, la interfaz puede contarlo, la prueba puede afirmarlo y el equipo puede leerlo. Cuando es un supuesto roto, lo único que queda es un informe de incidencia a las tres de la mañana.
- Toma un documento JSON real de un servicio que uses y enumera todas las suposiciones que tu tipo hace sobre él, campo por campo.
- Escribe la firma de
decodeStringde memoria y explica por qué el retorno es unResulty no el valor directamente. - Busca en un proyecto tuyo en otro lenguaje una aserción de tipo sobre datos externos y razona qué ocurriría si el servidor cambiara ese campo mañana.
- Argumenta por qué un
Decoderpuede guardarse en una lista o pasarse como argumento, y qué tiene eso que ver con la separación entre describir y ejecutar. - Explica por qué el código del dominio deja de necesitar comprobaciones defensivas cuando la única entrada pasa por un decodificador.
- Describe qué información debería mostrar tu interfaz cuando la decodificación falla, y por qué eso es una decisión de diseño y no un detalle técnico.