Casos difíciles: ausencia, uniones etiquetadas y valores de varias formas
Los documentos reales no son limpios. Un campo puede faltar, puede llegar nulo, puede faltar y además llegar nulo en respuestas distintas del mismo servicio, y esas tres situaciones no significan lo mismo aunque el modelo las colapse en un Maybe. Otros documentos son polimórficos: un objeto con una clave discriminadora cuyo valor decide qué campos vienen detrás, y para leerlo hace falta un decodificador que dependa de un valor ya decodificado, que es exactamente lo que ofrece andThen. Y otros llegan sencillamente con varias formas legítimas, sea por versiones conviviendo, sea por un servidor descuidado, y se atacan con oneOf, cuyo orden no es indiferente. Esta lección trata las tres familias con detalle, señala dónde cada atajo esconde una decisión de dominio y defiende que el decodificador es el sitio correcto para tomarla.
El material didáctico de cualquier biblioteca de serialización muestra siempre el mismo documento: un objeto plano, con todos sus campos presentes, con tipos consistentes y sin sorpresas. Los documentos que uno decodifica en producción no se parecen a ese. Hay campos que unas veces vienen y otras no, campos que vienen con valor nulo, campos que faltan en una versión de la interfaz y aparecen en la siguiente, objetos cuya forma depende del valor de una clave que hay que leer antes para saber qué leer después, y respuestas que llegan con dos o tres estructuras legítimas porque el servicio arrastra clientes viejos que nadie puede romper. La reacción habitual ante ese desorden es lamentarlo y taparlo con valores por defecto. La reacción productiva es reconocer que ese desorden es información sobre el dominio, y que el decodificador es el lugar exacto donde se decide qué significa cada irregularidad para tu aplicación. Esta lección recorre las tres familias de casos incómodos y sostiene una tesis a lo largo de las tres: cada atajo cómodo esconde una decisión de dominio, y tomarla explícitamente es más barato que descubrirla después.
- Distinguir con precisión un campo ausente, un campo con valor nulo y un campo presente pero vacío.
- Elegir entre
maybe,nullabley el valor por defecto según lo que la ausencia signifique en el dominio. - Decodificar uniones etiquetadas con
andThen,succeedyfaila partir de una clave discriminadora. - Aplicar
oneOfa documentos polimórficos entendiendo por qué el orden de las alternativas importa.
Tres ausencias que no son la misma
Empecemos por el error más extendido. Cuando un campo no aporta valor, hay al menos tres situaciones distintas y el modelo suele fundirlas en una sola. La clave puede no existir en el objeto, la clave puede existir con valor nulo, o la clave puede existir con un valor legítimo que representa vacío, como una cadena sin caracteres o una lista sin elementos. Si tu tipo las trata igual estás perdiendo información antes de haber empezado, y perdiéndola justo donde todavía se podía conservar.
-- La clave puede no estar: envolvemos la busqueda entera
maybe : Decoder a -> Decoder (Maybe a)
-- La clave esta y admite nulo: envolvemos solo el contenido
nullable : Decoder a -> Decoder (Maybe a)
-- Dos decodificadores que NO significan lo mismo
apodoA : Decoder (Maybe String)
apodoA =
maybe (field "apodo" string)
apodoB : Decoder (Maybe String)
apodoB =
field "apodo" (nullable string)
El primero tiene éxito si la clave falta, y también si la clave está pero contiene un número, porque lo que hace es tolerar cualquier fallo del decodificador que envuelve. El segundo exige que la clave exista y admite únicamente nulo como alternativa al texto: si llega un número, falla y te lo dice. La diferencia importa mucho más de lo que su brevedad sugiere. El primero es una red que atrapa errores reales del servidor y los convierte en ausencia; el segundo es una descripción fiel de un contrato donde el nulo está previsto. Usar el primero por comodidad es la manera más habitual de anular las garantías de todo el nivel.
maybe tolera el fallo
Envuelve un decodificador entero y convierte cualquier fracaso en ausencia. Cómodo y peligroso: silencia también los desajustes de tipo.
nullable admite el nulo
Exige que la clave exista y acepta solo nulo como alternativa. Describe un contrato en lugar de amortiguar un accidente.
andThen mira y decide
Lee un valor y devuelve el decodificador que hay que aplicar a continuación. Es lo que permite depender de una clave discriminadora.
oneOf prueba en orden
Intenta cada alternativa hasta que una tiene éxito. El orden es semántico: lo más específico primero, lo más laxo al final.
Uniones etiquetadas: decidir después de leer
El segundo caso difícil es el documento cuya forma depende de su contenido. Un objeto trae una clave que dice qué clase de cosa es, y el resto de los campos varía según ese valor. Ningún combinador de los vistos hasta ahora sirve, porque todos fijan de antemano el plan completo, y aquí el plan del resto depende de algo que solo se sabe tras leer una parte. La operación que introduce esa dependencia es andThen, y su firma cuenta exactamente lo que hace: recibe una función que, dado un valor decodificado, devuelve el siguiente decodificador.
andThen : (a -> Decoder b) -> Decoder a -> Decoder b
succeed : a -> Decoder a
fail : String -> Decoder a
type Evento
= Clic Int Int
| Tecla String
| Cierre
eventoDecoder : Decoder Evento
eventoDecoder =
field "tipo" string
|> andThen porTipo
porTipo : String -> Decoder Evento
porTipo tipo =
case tipo of
"clic" ->
map2 Clic (field "x" int) (field "y" int)
"tecla" ->
map Tecla (field "codigo" string)
"cierre" ->
succeed Cierre
otro ->
fail ("Tipo de evento desconocido: " ++ otro)
Este fragmento merece una lectura despacio porque reúne todo el nivel. La función auxiliar es una función corriente que recibe una cadena y devuelve un decodificador, y dentro de ella el case es el mismo que escribirías en cualquier otro punto del programa, con la exhaustividad comprobada por el compilador sobre las ramas que enumeres. La rama final es la más instructiva: fail convierte un valor que tu dominio no reconoce en un error explícito con mensaje. Es la diferencia entre un programa que admite que no entiende y uno que inventa un caso por defecto y contamina el modelo con un valor que nadie eligió.
Cuando aparece un valor discriminador desconocido hay dos salidas y solo una conserva la propiedad que hace valioso el nivel. Fallar con un mensaje deja el problema donde ocurrió, en la frontera, con la ruta del campo y el valor recibido a la vista; la aplicación mostrará un estado de error concreto y alguien podrá arreglarlo. Devolver un caso comodín, en cambio, empuja un valor inventado hacia el interior del dominio, donde se comportará como si fuera legítimo y producirá una pantalla incoherente sin ningún rastro del origen. Si tu dominio realmente admite lo desconocido, modélalo: añade un constructor que guarde la etiqueta original, y así lo desconocido será un caso previsto y no un accidente disfrazado.
Varias formas legítimas: oneOf y el orden
El tercer caso es el de la respuesta que llega con más de una estructura válida, sea porque conviven dos versiones de la interfaz, sea porque un campo acepta tanto un valor suelto como una lista, sea porque el servidor envía números como texto en unos sitios y como números en otros. La herramienta es una función que recibe una lista de alternativas y prueba cada una en orden hasta que alguna tiene éxito, devolviendo el error acumulado si ninguna lo consigue.
oneOf : List (Decoder a) -> Decoder a
-- Un identificador que llega como numero o como texto
identificador : Decoder Int
identificador =
oneOf
[ int
, string |> andThen textoAEntero
]
textoAEntero : String -> Decoder Int
textoAEntero texto =
case String.toInt texto of
Just n ->
succeed n
Nothing ->
fail ("No es un entero valido: " ++ texto)
El orden de la lista no es un detalle de estilo, es semántica. Las alternativas se prueban de arriba abajo y gana la primera que encaja, de modo que colocar una alternativa laxa antes que una estricta hace que la estricta no llegue a ejecutarse nunca. La regla práctica es sencilla de enunciar y fácil de olvidar: lo más específico primero, lo más permisivo al final, y jamás una alternativa que acepte cualquier cosa salvo que quieras que las demás sean decorativas.
flowchart TD Doc[Documento con clave tipo] --> T[field tipo string] T --> AT[andThen elige el plan] AT --> C1[Decoder de clic] AT --> C2[Decoder de tecla] AT --> C3[fail para lo desconocido] C1 --> V[Valor del dominio] C2 --> V C3 --> E[Error con causa en la frontera] style AT fill:#89b4fa,color:#11111b style V fill:#a6e3a1,color:#11111b style E fill:#f38ba8,color:#11111b
La lección oculta de estos tres casos es que ninguno es un problema técnico. Que un campo pueda faltar no es un accidente del transporte: es una afirmación sobre el dominio, dice que existen entidades legítimas sin ese dato, y si es verdad hay que modelarlo y si es mentira hay que rechazarlo. Que un objeto cambie de forma según una etiqueta tampoco es un capricho del servidor: es una unión etiquetada que ya existía en el modelo de negocio y que el JSON representa como puede, porque su vocabulario de tipos es demasiado pobre para expresarla. Que un identificador llegue unas veces como número y otras como texto no es una excentricidad: es la huella de una historia, de dos versiones que convivieron, de una migración a medias, y esa historia es información real sobre el sistema con el que trabajas. Cuando estas irregularidades se resuelven con un valor por defecto puesto deprisa, ocurren dos cosas malas a la vez y solo la primera se nota. La visible es que un dato inventado entra en el modelo y produce, semanas después, una pantalla que nadie sabe explicar. La invisible, y peor, es que se ha destruido la evidencia: el punto donde el mundo y el modelo no coincidían era el único lugar donde ese desacuerdo era observable, y taparlo lo borra del registro. El decodificador es, por eso, mucho más que una capa de conversión. Es el documento vivo del contrato entre tu aplicación y el exterior, el sitio donde queda escrito qué se acepta, qué se rechaza y con qué palabras se explica el rechazo. Escribirlo con cuidado no es una tasa que se paga por usar un lenguaje estricto; es la única ocasión que el proyecto tendrá de decidir, con la información delante y el compilador de testigo, qué está dispuesto a creerse.
- Escribe los dos decodificadores de apodo, con
maybefuera y connullabledentro, y construye un documento que los distinga. - Modela un dominio donde ausencia y vacío signifiquen cosas distintas y explica qué se pierde al fundirlos en un solo caso.
- Decodifica una unión etiquetada de tres constructores con
andTheny haz que un valor desconocido produzca unfailcon mensaje útil. - Sustituye ese
failpor un caso comodín y describe con detalle el recorrido del dato inventado hasta la pantalla. - Usa
oneOfpara admitir un identificador numérico o textual, invierte el orden de las alternativas y explica el efecto. - Añade un constructor que guarde la etiqueta desconocida original y argumenta cuándo esa es la modelización correcta.