wandres.dev
DECODIFICADORES JSON · datos del exterior

Decodificadores básicos: primitivos, campos y estructuras

Un decodificador complejo no se escribe de golpe: se compone a partir de piezas mínimas cuya única virtud es hacer una sola cosa y decir la verdad sobre ella. Esta lección recorre el vocabulario elemental de la biblioteca: los primitivos string, int, float y bool, que aceptan exactamente una forma de valor y rechazan cualquier otra; la función field, que no es un accesor sino un combinador que toma un decodificador y lo aplica dentro de una clave; sus parientes at e index para rutas anidadas y posiciones de un array; y los constructores estructurales list, dict y keyValuePairs, que replican un decodificador sobre una colección entera. El hilo conductor es una idea que hay que ver antes de poder componer nada: en esta biblioteca todo tiene el mismo tipo, todo devuelve un Decoder, y por eso todo encaja con todo sin adaptadores ni casos especiales.

⏱ 18 min

La biblioteca de decodificación tiene un aire engañosamente pobre la primera vez que se mira: una docena de nombres cortos, ninguna sintaxis propia, ningún generador, ninguna anotación mágica. Esa pobreza es deliberada y es exactamente lo que la hace potente. Cada nombre designa un valor de tipo Decoder, cada uno describe una comprobación mínima e indivisible, y ninguno sabe nada de los demás. La riqueza no está en las piezas sino en que todas comparten el mismo tipo, y por tanto se pueden encajar unas dentro de otras sin que nadie haya previsto la combinación concreta que a ti te hace falta. Antes de aprender a construir un registro campo a campo conviene dominar este vocabulario elemental, porque todo lo que viene después es aplicación repetida de tres ideas: un primitivo comprueba una forma, un combinador coloca un decodificador dentro de otro sitio, y una estructura repite un decodificador sobre muchos valores. Quien entiende que field no es un accesor sino un combinador ya ha entendido la biblioteca entera.

🎯 Al terminar esta lección sabrás
  • Usar los decodificadores primitivos y describir con exactitud qué acepta y qué rechaza cada uno.
  • Leer field como un combinador que recibe un decodificador y devuelve otro, no como una lectura de propiedad.
  • Recorrer estructuras anidadas con at e index y justificar cuándo cada uno es preferible.
  • Replicar un decodificador sobre colecciones con list, dict y keyValuePairs sin escribir bucles.

Primitivos: una comprobación mínima, ninguna conversión

Los decodificadores primitivos son la capa de contacto con el documento y su comportamiento es más estricto de lo que muchos esperan. El decodificador de cadenas acepta un texto JSON y rechaza cualquier otra cosa, incluido un número que casualmente se parece a un texto. El de enteros acepta un número sin parte decimal y rechaza la cadena que lo representa. No hay coerción, no hay conversión indulgente, no hay reglas de compatibilidad heredadas del navegador. Esa rigidez es una elección de diseño: un decodificador indulgente convierte un error del servidor en un dato silenciosamente equivocado, que es peor que un fallo ruidoso.

-- Los primitivos: cada uno acepta una sola forma
string : Decoder String
int    : Decoder Int
float  : Decoder Float
bool   : Decoder Bool


-- Y se ejecutan igual que cualquier otro plan
decodeString string "\"Ada\""   -- Ok "Ada"
decodeString int    "\"42\""    -- Err: se esperaba un entero
decodeString int    "42"        -- Ok 42

El tercer caso frente al segundo resume la filosofía. Una cadena que contiene dígitos no es un entero, y el decodificador no va a fingir que sí para ahorrarte una conversación con el equipo del servidor. Si de verdad recibes números como texto, existe una forma explícita de decirlo, que veremos en la lección de casos difíciles, y lo importante es que sea explícita: en el código quedará escrito que ese campo llega mal tipado desde el origen, que es información valiosa para quien mantenga el proyecto.

🔤

string, int, float, bool

Los átomos. Aceptan una forma y solo una. Ninguno convierte, ninguno adivina, ninguno rellena un valor por defecto en silencio.

🔑

field

Combinador, no accesor. Recibe un nombre de clave y un decodificador, y devuelve un decodificador que trabaja dentro de esa clave.

🧭

at e index

at recorre una ruta de claves anidadas; index entra en una posición de un array. Ambos siguen devolviendo un Decoder.

📚

list y dict

Toman un decodificador de elemento y lo aplican a toda la colección. Si un solo elemento falla, falla el conjunto y el error dice cuál.

field no lee un campo: lo atraviesa

Aquí está el giro conceptual de la lección y conviene detenerse en él. En la mayoría de las bibliotecas de deserialización existe una operación que significa dame el valor de esta clave, y su resultado es un dato. En Elm, field no produce un dato: produce otro decodificador. Recibe el nombre de la clave y un decodificador que describe qué hay dentro, y devuelve un plan nuevo cuyo significado es entra en esa clave y aplica ahí el plan que te di. Por eso su tipo es cerrado sobre sí mismo, y por eso se puede anidar indefinidamente sin que la biblioteca necesite prever ninguna profundidad.

-- Un combinador: recibe un Decoder y devuelve un Decoder
field : String -> Decoder a -> Decoder a
at    : List String -> Decoder a -> Decoder a
index : Int -> Decoder a -> Decoder a


-- Anidar es aplicar field dentro de field
nombreDecoder : Decoder String
nombreDecoder =
    field "usuario" (field "perfil" (field "nombre" string))


-- Y at es exactamente eso, escrito de un tirón
mismoNombre : Decoder String
mismoNombre =
    at [ "usuario", "perfil", "nombre" ] string

La equivalencia entre las dos últimas definiciones no es una casualidad simpática: es la prueba de que at no añade poder expresivo, solo comodidad de lectura. Todo lo que la biblioteca puede hacer se puede reconstruir componiendo piezas más pequeñas, y esa propiedad es la que hace que no haya casos especiales que memorizar. Cuando aparezca una forma de documento que nadie previó, la respuesta no será buscar la función que la cubra, sino combinar las que ya conoces.

💡
Un decodificador puede empezar en cualquier profundidad

Una consecuencia práctica que ahorra mucho código: nada obliga a que un decodificador describa el documento entero. Si de una respuesta enorme solo te interesan tres campos, escribe un decodificador que extraiga exactamente esos tres y olvide el resto sin protestar. La decodificación no exige exhaustividad sobre la entrada, exige exhaustividad sobre la salida: todo lo que tu tipo necesita tiene que estar justificado, pero todo lo que el documento traiga de más es irrelevante. Esta asimetría es la que permite que tu aplicación sobreviva a que el servidor añada campos nuevos, que es de largo el cambio más frecuente en cualquier interfaz de red.

Estructuras: repetir un plan sobre una colección

El tercer grupo de piezas toma un decodificador de elemento y lo replica. La función que decodifica listas recibe el plan de un elemento y devuelve el plan de la colección completa; si un solo elemento no encaja, el conjunto falla y el mensaje de error incluye el índice culpable. La que decodifica diccionarios asume claves de texto y aplica el mismo plan a todos los valores, lo cual es la respuesta correcta cuando el servidor envía un mapa de identificadores en lugar de un array. Y hay una variante que entrega los pares tal cual, útil cuando las claves importan y no son homogéneas.

-- Replicar un plan sobre una coleccion
list          : Decoder a -> Decoder (List a)
dict          : Decoder a -> Decoder (Dict String a)
keyValuePairs : Decoder a -> Decoder (List ( String, a ))


-- Composicion directa: campos, listas y anidamiento
etiquetas : Decoder (List String)
etiquetas =
    field "etiquetas" (list string)


puntuaciones : Decoder (Dict String Int)
puntuaciones =
    at [ "datos", "puntuaciones" ] (dict int)

Observa que en ninguna de estas definiciones aparece un bucle, un índice o una variable temporal. No hay recorrido explícito porque no hay recorrido en absoluto en el código que escribes: hay una descripción de la forma esperada, y el recorrido lo hace la implementación de la biblioteca cuando ejecutas el plan. Es la misma distancia que separa una consulta declarativa de un bucle imperativo, y produce el mismo efecto sobre la legibilidad: el código se parece al documento, no al proceso de leerlo.

El error sabe exactamente dónde falló

Queda una pieza del vocabulario elemental que no es un decodificador y que sin embargo justifica buena parte del diseño: el tipo del error. Cuando la ejecución de un plan fracasa, lo que obtienes no es una cadena genérica sino un valor estructurado que refleja el camino recorrido hasta el punto del desacuerdo. Cada field atravesado añade su clave, cada index añade su posición, y cada alternativa fallida de una combinación deja constancia. El resultado es un mensaje que señala la ruta completa en lugar de limitarse a decir que algo no encajó.

-- El error conserva la ruta y sabe imprimirse
errorToString : Error -> String


-- Un fallo en el tercer elemento de una lista anidada dice algo asi:
-- Problema en el valor de la ruta datos.usuarios[2].edad
-- Se esperaba un entero pero se recibio una cadena

Esta precisión tiene una consecuencia práctica que va más allá de la comodidad al depurar. Como el error es un dato con estructura y no un texto, se puede almacenar en el modelo, mostrar en la interfaz durante el desarrollo, registrar en un servicio de telemetría o comparar en una prueba. Y como la ruta es exacta, la conversación con el equipo que mantiene el servicio deja de ser una discusión sobre impresiones y pasa a ser un señalamiento verificable: este campo, en esta posición, con este tipo y no aquel.

flowchart TD
P[Primitivos string int bool] --> C[Combinadores field at index]
C --> E[Estructuras list dict keyValuePairs]
E --> D[Decoder compuesto]
P --> D
D --> R[Result con dato o con ruta del fallo]
style C fill:#89b4fa,color:#11111b
style D fill:#cba6f7,color:#11111b
style R fill:#a6e3a1,color:#11111b
Un vocabulario cerrado sobre sí mismo vale más que un catálogo de casos

Lo que hace útil a esta biblioteca no es la cantidad de situaciones que cubre, sino que todas sus piezas tengan el mismo tipo de retorno. Ese detalle, que parece contable, es en realidad la propiedad estructural que decide si una herramienta escala o se agota. Compara con el diseño habitual de las bibliotecas de deserialización por anotaciones: cada capacidad nueva se expresa con una anotación nueva, una para renombrar la clave, otra para el valor por defecto, otra para el formato de fecha, otra para el campo discriminador de una jerarquía, y el catálogo crece sin límite porque el mundo produce formas que nadie enumeró. Cuando aparece una forma que ninguna anotación cubre, el usuario se queda sin salida dentro del sistema y tiene que escaparse a un mecanismo aparte, escribir un deserializador manual y perder de golpe toda la uniformidad. La biblioteca de decodificadores no puede quedarse sin salida, porque no está hecha de casos sino de un álgebra: hay unos pocos elementos y unas pocas operaciones que toman decodificadores y devuelven decodificadores, así que la respuesta a cualquier forma nueva se construye en el mismo lenguaje en que están escritas las respuestas viejas. Esta es la razón profunda por la que field devuelve un Decoder y no un valor, y por la que merece la pena resistirse a leerlo como un accesor aunque en el ejemplo más simple se comporte como si lo fuera. Leerlo como accesor te permite copiar ejemplos; leerlo como combinador te permite inventar los tuyos. Y hay una consecuencia menos evidente que se aprecia con los años: como todas las piezas son valores ordinarios, se pueden nombrar y compartir, de modo que el decodificador de un identificador o de una fecha del dominio se escribe una vez, se prueba una vez y se reutiliza en cada punto donde ese concepto aparezca. La forma del documento deja de estar dispersa en anotaciones repartidas por las clases y pasa a estar reunida en valores con nombre, que es donde el equipo puede verla, discutirla y cambiarla.

⚔️ Compón el vocabulario elemental
  1. Ejecuta el decodificador de enteros contra un texto que contenga dígitos entre comillas y explica por qué el rechazo es una buena noticia.
  2. Escribe la firma de field de memoria y justifica en una frase por qué su retorno es un Decoder y no el valor de la clave.
  3. Expresa la misma ruta anidada dos veces, una con field encadenado y otra con at, y razona qué gana cada versión.
  4. Decodifica una lista de cadenas situada dentro de dos niveles de objeto sin escribir ningún recorrido explícito.
  5. Elige entre dict y keyValuePairs para un mapa cuyas claves son identificadores con significado, y defiende la elección.
  6. Escribe un decodificador que ignore deliberadamente la mitad de los campos del documento y explica por qué eso hace tu aplicación más resistente al cambio.