Codificadores: salir del dominio y mantener los dos lados sincronizados
Decodificar resuelve la mitad del problema de la frontera; la otra mitad es salir. Cuando tu aplicación envía un formulario, guarda un estado en el almacenamiento del navegador o habla con una biblioteca de JavaScript, tiene que convertir sus tipos en un valor JSON, y esa dirección es más sencilla porque parte de datos que ya son de fiar. Esta lección presenta la biblioteca de codificación como el reflejo exacto de la anterior: unos pocos primitivos, una función para listas que recibe el codificador del elemento, y una función para objetos que recibe pares de clave y valor. Después aborda el problema serio, que no es escribir el codificador sino evitar que se desincronice del decodificador cuando el modelo cambia, y propone la respuesta más sólida disponible: una prueba de ida y vuelta con datos generados que falla en cuanto ambas descripciones dejan de coincidir.
Todo lo anterior trata de entrar: convertir algo sin garantías en algo de fiar. Falta la dirección contraria, que es la de salir, y conviene decir desde el principio que es mucho menos peligrosa. Cuando tu programa codifica, parte de un valor que ya tiene tipo, que ya fue construido por código tuyo y que ya cumple las invariantes del dominio; no hay nada que comprobar porque no hay nada de lo que desconfiar. Por eso la biblioteca de codificación no devuelve un Result: produce directamente un valor JSON, sin posibilidad de fracaso. Esa asimetría es la primera cosa que hay que entender, porque explica de un plumazo por qué el código de salida es tan corto comparado con el de entrada. Ahora bien, la facilidad esconde el problema real de esta lección, que no es escribir el codificador sino algo mucho menos vistoso: garantizar que la descripción de entrada y la de salida sigan hablando del mismo documento seis meses después, cuando alguien añada un campo al modelo, actualice una de las dos y olvide la otra.
- Explicar por qué codificar no puede fallar y qué consecuencia tiene eso sobre las firmas de la biblioteca.
- Construir valores JSON con los primitivos, con la función de listas y con la de objetos.
- Codificar una unión etiquetada eligiendo la clave discriminadora que su decodificador espera.
- Diseñar una prueba de ida y vuelta con datos generados que detecte la desincronización entre ambos lados.
Codificar no puede fallar
La biblioteca de salida es la imagen especular de la de entrada, con una diferencia que se ve en cada firma. Allí donde el decodificador prometía un plan que podía fracasar, aquí hay funciones totales que convierten un valor de tu dominio en un valor JSON y ya está. El tipo del resultado es opaco, es decir, no puedes inspeccionarlo ni recorrerlo desde Elm; solo puedes construirlo, entregarlo a una petición de red o convertirlo en texto con una función que además recibe la sangría deseada.
import Json.Encode as E
-- Primitivos: totales, sin Result a la vista
E.string : String -> E.Value
E.int : Int -> E.Value
E.bool : Bool -> E.Value
E.null : E.Value
-- Estructuras: la lista recibe el codificador del elemento
E.list : (a -> E.Value) -> List a -> E.Value
E.object : List ( String, E.Value ) -> E.Value
-- Y la salida a texto, con sangria explicita
E.encode : Int -> E.Value -> String
Fíjate en la firma de la función de listas, porque es donde vive la simetría con el otro lado. Igual que el decodificador de listas recibía el plan del elemento y devolvía el de la colección, aquí la función recibe el codificador del elemento y lo aplica a todos. En ambos casos el conocimiento sobre el elemento se pasa como argumento en lugar de resolverse por reflexión o por anotación, y esa es la razón por la que ninguna de las dos bibliotecas necesita saber nada de tus tipos.
Sin Result
Codificar parte de datos que ya cumplen sus invariantes. No hay nada que comprobar, así que no hay nada que pueda fallar.
object toma pares
Una lista de pares de clave y valor. Construir el documento es construir una lista, con todo lo que eso permite hacer antes de entregarla.
Simetría explícita
La función de listas recibe el codificador del elemento igual que su gemela recibía el decodificador. Ninguna usa reflexión.
Ida y vuelta
La única defensa fiable contra la desincronización: codificar, decodificar y exigir que el valor recuperado sea idéntico al original.
Codificar una unión etiquetada
El caso interesante es el mismo que en la lección anterior, visto del revés. Si el decodificador esperaba una clave discriminadora para saber qué leer, el codificador tiene que producir esa clave con el valor exacto que el otro lado reconoce. Aquí la exhaustividad del case trabaja a tu favor: si mañana añades un constructor al tipo, el compilador señalará el codificador como incompleto y no podrás olvidarlo.
codificarEvento : Evento -> E.Value
codificarEvento evento =
case evento of
Clic x y ->
E.object
[ ( "tipo", E.string "clic" )
, ( "x", E.int x )
, ( "y", E.int y )
]
Tecla codigo ->
E.object
[ ( "tipo", E.string "tecla" )
, ( "codigo", E.string codigo )
]
Cierre ->
E.object [ ( "tipo", E.string "cierre" ) ]
Lo que el compilador comprueba aquí y lo que no comprueba conviene tenerlo muy claro, porque en esa distinción está el riesgo del resto de la lección. Comprueba que trates todos los constructores del tipo, comprueba que cada valor codificado tenga el tipo adecuado y comprueba que el resultado sea un valor JSON bien formado. No comprueba, ni puede comprobar, que la cadena que escribes como discriminador coincida con la que el decodificador espera, ni que los nombres de las claves sean los mismos en los dos ficheros. Esas dos descripciones son independientes para el sistema de tipos, y su acuerdo depende únicamente de que alguien lo mantenga.
El valor codificado no suele quedarse quieto: se entrega a quien vaya a cruzar la puerta. En una petición de red se envuelve en un cuerpo, en un puerto se emite tal cual, y cuando hace falta texto se pasa por la función de salida indicando la sangría, donde el cero produce una línea compacta y cualquier número mayor produce un documento legible para un humano.
-- Cuerpo de una peticion
guardarEvento : Evento -> Cmd Msg
guardarEvento evento =
Http.post
{ url = "/api/eventos"
, body = Http.jsonBody (codificarEvento evento)
, expect = Http.expectJson Guardado eventoDecoder
}
-- O a texto plano, con sangria elegida
E.encode 2 (codificarEvento Cierre)
Observa que en la misma petición conviven las dos direcciones: el cuerpo lleva un codificador y la expectativa lleva un decodificador. Es la imagen más nítida de la frontera, con la salida y la entrada declaradas a la vez y a la vista, y también el recordatorio más claro de que ambas descripciones tienen que hablar del mismo esquema aunque el compilador no las relacione.
Es habitual asociar la codificación con la red y olvidar los otros dos cruces, que son igual de reales y suelen estar peor cuidados. Guardar el estado en el almacenamiento del navegador es escribir un documento hoy y leerlo dentro de un año, con una versión de la aplicación que quizá haya cambiado el modelo por medio; ahí el decodificador se enfrenta a datos producidos por un programa que ya no existe, y conviene tratarlos con la misma desconfianza que a un servidor ajeno. Los puertos hacia JavaScript son el segundo caso: lo que sale por un puerto es un valor JSON y lo que entra vuelve como un valor opaco que hay que decodificar, sin excepción y sin atajos. La frontera no es la red; la frontera es todo aquello que tu compilador no vio nacer.
La prueba de ida y vuelta
Llegamos al problema serio. Tienes dos descripciones de la misma forma escritas en sitios distintos, y nada en el lenguaje obliga a que coincidan. El día que alguien añada un campo al modelo, el compilador exigirá actualizar el decodificador, porque el registro no se puede construir sin él, y en cambio no exigirá nada del codificador si ese campo simplemente se omite del objeto emitido. El resultado es una aplicación que lee bien y escribe mal, o al revés, y el fallo aparecerá lejos del cambio que lo causó.
-- Propiedad: codificar y decodificar devuelve el valor original
idaYVuelta : Test
idaYVuelta =
fuzz usuarioFuzzer "codificar y decodificar es la identidad" <|
\usuario ->
usuario
|> codificarUsuario
|> Json.Decode.decodeValue usuarioDecoder
|> Expect.equal (Ok usuario)
Esta prueba es corta y hace un trabajo desproporcionado a su tamaño. Al generar valores del dominio al azar, explora combinaciones que nadie habría escrito a mano, incluidas las que rondan los bordes: cadenas vacías, listas sin elementos, campos opcionales ausentes, uniones en su constructor más raro. Y su condición de éxito es la única que de verdad importa aquí, que es que las dos descripciones sean inversas la una de la otra. En cuanto alguien toque un nombre de clave, cambie una etiqueta discriminadora o añada un campo a un solo lado, la prueba falla y falla en el momento del cambio, no meses después en la máquina de un usuario.
flowchart LR V[Valor del dominio] --> C[Codificador] C --> J[Valor JSON] J --> D[Decodificador] D --> R[Result con el valor recuperado] R --> P[Prueba de igualdad con el original] style C fill:#89b4fa,color:#11111b style D fill:#cba6f7,color:#11111b style P fill:#a6e3a1,color:#11111b
Aquí conviene terminar el nivel con una incomodidad en lugar de con una celebración, porque la incomodidad es real y esconderla sería mal servicio. Escribir a mano el decodificador y el codificador tiene un coste que los generadores automáticos no tienen, y ese coste no está en teclear: está en que el proyecto sostiene dos afirmaciones sobre la misma forma que el compilador no puede relacionar entre sí. Un generador que deriva ambos lados de una sola declaración elimina esa duplicación de un golpe, y quien defienda el enfoque manual tiene que reconocer honestamente lo que se pierde. Lo que se gana a cambio es la capacidad de que las dos formas no sean iguales, que en la práctica es lo normal y no la excepción: el documento que el servidor emite casi nunca tiene la forma que a tu aplicación le conviene tener en memoria, los nombres de sus claves responden a la historia de otro sistema, sus estructuras planas ocultan uniones que tú quieres explícitas, y sus fechas y cantidades llegan en representaciones que tu dominio no debería tolerar. Un derivador automático te obliga a modelar tu interior a imagen del exterior, y esa es exactamente la dependencia que la decodificación explícita rompe: el decodificador es una traducción, y una traducción admite que los dos idiomas tengan gramáticas distintas. La contrapartida es que la duplicación queda a cargo del equipo, y la única disciplina que la sostiene sin depender de la memoria de nadie es la prueba de ida y vuelta con datos generados. Merece la pena ver lo que esa prueba es en realidad: no comprueba un caso, comprueba una propiedad algebraica, la de que dos funciones sean inversas sobre todo el dominio. Es un teorema pequeño, verificado empíricamente sobre cientos de valores en cada ejecución, y ocupa cinco líneas. Cuando existe, la duplicación deja de ser una deuda y pasa a ser una elección: dos descripciones distintas porque describen cosas distintas, atadas por una comprobación que grita en cuanto se separan.
- Escribe el codificador de un registro de cuatro campos y explica por qué su firma no menciona ningún
Result. - Codifica una unión etiquetada de tres constructores y comprueba qué dice el compilador cuando añades un cuarto al tipo.
- Cambia el nombre de una clave solo en el codificador y describe cuánto tardaría tu proyecto en descubrirlo sin pruebas.
- Escribe la prueba de ida y vuelta con datos generados y haz que falle deliberadamente para ver el valor que la rompe.
- Guarda un estado en el almacenamiento del navegador y razona por qué al leerlo hay que desconfiar igual que de un servidor ajeno.
- Argumenta en qué situación un derivador automático sería preferible y en cuál te ataría a la forma del sistema ajeno.