Modelar el error del dominio con un tipo propio
Un Result cuyo lado izquierdo es un String funciona, pero desperdicia casi todo el poder del mecanismo: la causa del fallo queda escrita en un formato que el compilador no puede inspeccionar, que no se puede comparar sin comparar cadenas, que mezcla el diagnóstico técnico con el mensaje al usuario y que impide traducir o probar sin recurrir a texto frágil. Esta lección desarrolla la alternativa: declarar un tipo de unión propio que enumere las causas posibles del dominio, cada una con los datos que la explican. Se estudia cómo esa enumeración convierte el case exhaustivo en una lista de tareas verificada por el compilador, cómo separar el error como dato del mensaje como presentación, cómo componer errores de varias capas con un tipo envolvente y mapError, y por qué añadir una causa nueva rompe la compilación en todos los sitios que debían enterarse. La tesis es que el error es parte del modelo del dominio y merece el mismo cuidado de diseño que el éxito.
Casi todo el mundo escribe su primer Result con una cadena en el lado del error, y es una etapa razonable: funciona, se lee y muestra algo al usuario. Pero conviene ver pronto lo que se está perdiendo, porque es casi todo. Una cadena es opaca para el compilador: puede contener cualquier cosa, así que no hay forma de enumerar las causas posibles, ni de comprobar que las has tratado todas, ni de saber si dos errores son el mismo salvo comparando texto letra por letra. Es también una mezcla desafortunada de dos cosas que deberían vivir separadas: el diagnóstico —qué ha ocurrido exactamente, en términos del dominio— y la presentación —qué frase leerá una persona concreta en su idioma—. Y es, además, un dato sin estructura: si el fallo necesita transportar el campo que falló, el número de intentos o el código que devolvió el servidor, la cadena solo puede recogerlos disolviéndolos en su interior, de donde ya nadie podrá recuperarlos. La alternativa es hacer con el error lo mismo que ya hiciste con el éxito: modelarlo.
- Reconocer los cuatro defectos de usar un
Stringcomo tipo de error: opacidad, mezcla, falta de estructura y fragilidad. - Declarar un tipo de unión propio que enumere las causas de fallo del dominio con los datos que cada una necesita.
- Separar el error como dato de su representación textual mediante una función pura de presentación.
- Componer errores de distintas capas con un tipo envolvente y
mapError, sin aplanar la información.
El error como parte del modelo
La declaración es idéntica a la de cualquier otro tipo de tu dominio, porque el error es tu dominio. Nombra cada motivo por el que la operación puede rechazar el trabajo y adjunta a cada uno los datos que hacen falta para explicarlo o para actuar en consecuencia.
-- Cada causa tiene nombre propio y lleva consigo lo que la explica
type ErrorRegistro
= CorreoSinArroba String
| ClaveDemasiadoCorta Int
| ClaveSinNumeros
| UsuarioYaExiste String
| ServidorNoDisponible { intentos : Int, codigo : Int }
registrar : Formulario -> Result ErrorRegistro Usuario
Compara la firma final con la que tenías antes. Result String Usuario decía puede fallar y te contaré algo. Result ErrorRegistro Usuario dice puede fallar exactamente de cinco maneras, aquí están sus nombres, y algunas traen datos. La segunda firma es documentación ejecutable: para saber qué puede salir mal no hay que leer el cuerpo de la función ni confiar en un comentario, basta con abrir la definición del tipo, que además es imposible que se desactualice porque el compilador la usa.
Fíjate en ServidorNoDisponible, que transporta un registro con el número de intentos y el código de respuesta. Con una cadena, esa información solo podría existir ya cocinada dentro de una frase, y si más tarde quisieras reintentar únicamente ante ciertos códigos tendrías que analizar el texto para recuperar un dato que tú mismo habías destruido al formatearlo. Con un tipo propio, el dato sigue siendo un dato hasta el último momento y la decisión de convertirlo en prosa se toma una sola vez, al final, donde corresponde.
El compilador como lista de tareas
Aquí ocurre lo que hace que todo el esfuerzo merezca la pena. Como el tipo enumera sus casos y case debe ser exhaustivo, el compilador puede verificar que tratas todas las causas, y sobre todo puede avisarte cuando aparece una nueva.
-- La presentacion vive separada del diagnostico
mensajeParaUsuario : ErrorRegistro -> String
mensajeParaUsuario error =
case error of
CorreoSinArroba texto ->
"El correo " ++ texto ++ " no tiene un formato valido"
ClaveDemasiadoCorta longitud ->
"La clave tiene " ++ String.fromInt longitud ++ " caracteres y necesita ocho"
ClaveSinNumeros ->
"La clave debe incluir al menos un numero"
UsuarioYaExiste nombre ->
"Ya existe una cuenta con el nombre " ++ nombre
ServidorNoDisponible _ ->
"No hemos podido conectar. Intentalo de nuevo en unos minutos"
Imagina ahora que el producto añade una regla y aparece un constructor ClaveEnListaNegra. En el momento en que lo escribes en el tipo, el compilador deja de aceptar el proyecto y te enumera todos los case que ya no son exhaustivos: el que produce el mensaje, el que decide si conviene reintentar, el que registra métricas, el que colorea el campo del formulario. Ninguno se te puede olvidar, porque olvidarlo no es una posibilidad. El sistema de tipos se comporta como una lista de tareas que se genera sola, que está siempre completa y que no se puede cerrar sin hacerla.
Causas con nombre
Cada fallo del dominio es un constructor, no una frase. Se compara por igualdad, se agrupa, se cuenta y se razona sobre él como sobre cualquier dato.
Datos adjuntos
El constructor transporta lo que hace falta para explicar o reaccionar: el campo culpable, el código del servidor, el número de intentos.
Presentación aparte
Una función pura convierte el error en texto. Cambiar el idioma o el tono es cambiar esa función, sin tocar ni una línea de la lógica.
Exhaustividad viva
Añadir una causa rompe la compilación en todos los lugares que debían enterarse; el refactor deja de depender de la memoria de nadie.
Escribir la causa dentro de un texto es la versión del error de lo que en tipos se conoce como el vicio de codificarlo todo en cadenas. El síntoma clásico llega meses después, cuando alguien necesita distinguir dos fallos y acaba escribiendo una comprobación que pregunta si el mensaje contiene cierta palabra. En ese instante el texto destinado a un ser humano se ha convertido en un protocolo entre partes del programa, y cualquier corrección de estilo o traducción al inglés romperá silenciosamente la lógica. El tipo de unión evita la trampa de raíz: el programa razona sobre constructores, las personas leen frases, y la función que va de unos a otras es el único puente, explícito y en un solo sitio.
Componer errores de varias capas
Un programa real apila capas, y cada una tiene su propio vocabulario de fallos. La decodificación puede fracasar en términos de JSON, la red en términos de HTTP y la validación en términos de negocio. La tentación es aplanar todo a un tipo común lo antes posible, y suele ser un error: aplanar es perder. Lo que se hace es envolver.
-- Cada capa conserva su vocabulario y el nivel superior las agrupa
type ErrorApp
= DeValidacion ErrorRegistro
| DeRed Http.Error
| DeFormato Decode.Error
-- mapError adapta la causa sin tocar el exito
guardar : Formulario -> Result ErrorApp Usuario
guardar formulario =
validar formulario
|> Result.mapError DeValidacion
|> Result.andThen (\usuario -> enviar usuario |> Result.mapError DeRed)
Este apilamiento tiene además una virtud que se nota al depurar. Cuando un fallo llega a la cima envuelto en DeFormato, sabes sin ambigüedad que el problema está en el contrato con el servidor y no en lo que escribió el usuario; cuando llega como DeValidacion, sabes que el usuario debe corregir algo y que no hay nada que reintentar. Esa clasificación, que en un sistema con cadenas exigiría leer el texto y adivinar, aquí la da el constructor externo antes de mirar su contenido.
mapError es la pieza que permite este ensamblaje: adapta el lado del fallo a un vocabulario más amplio dejando intacto el del éxito, de manera que una función escrita para hablar solo de errores de validación puede encajar en una tubería que también contempla los de red. La información se conserva entera en cada salto, porque el constructor envolvente añade contexto en vez de sustituirlo. Y en la cima, un solo case sobre ErrorApp puede decidir tratos radicalmente distintos: reintentar los fallos de red, mostrar los de validación junto al campo culpable y enviar los de formato a la telemetría, porque un JSON inesperado es un defecto tuyo y no del usuario.
flowchart LR V[Validacion] -->|mapError DeValidacion| A[ErrorApp] R[Red] -->|mapError DeRed| A D[Decodificacion] -->|mapError DeFormato| A A --> C[case exhaustivo] C --> U[Mensaje al usuario] C --> T[Reintento o telemetria] style A fill:#89b4fa,color:#11111b style C fill:#a6e3a1,color:#11111b
El error tipado dentro del modelo y de la vista
Modelar el error rinde su último dividendo cuando el fallo deja de ser algo que ocurre y pasa a ser algo que está: un campo del estado. Guardar en el modelo el valor tipado, y no la frase ya formateada, mantiene abiertas todas las decisiones hasta el momento de pintar.
type alias Model =
{ formulario : Formulario
, problema : Maybe ErrorRegistro
}
-- La vista decide donde y como se muestra cada causa, con el dato aun intacto
verProblema : Maybe ErrorRegistro -> Html Msg
verProblema problema =
case problema of
Nothing ->
text ""
Just (ServidorNoDisponible detalle) ->
avisoConReintento (mensajeParaUsuario (ServidorNoDisponible detalle))
Just error ->
avisoEnLinea (campoAfectado error) (mensajeParaUsuario error)
Fíjate en lo que permite tener el error tipado y no su texto: la vista puede preguntar al propio error qué campo del formulario debe resaltar, puede ofrecer un botón de reintento solo en la causa que lo admite y puede aplicar un tono distinto a un fallo del usuario que a un fallo del sistema. Con una cadena guardada en el modelo, las tres decisiones serían imposibles sin volver a analizar el texto. El error ha dejado de ser un residuo del cálculo para convertirse en un dato de primera clase que la interfaz consulta como consultaría cualquier otro.
Existe un desequilibrio casi universal en la forma en que se escribe software: se dedica enorme cuidado a modelar el éxito —los tipos del pedido, del usuario, de la factura, con sus invariantes y sus nombres precisos— y se despacha el fracaso con una cadena improvisada en el momento de escribirla. El desequilibrio es injustificable, porque en un sistema real la mayoría de los caminos posibles son caminos de fallo, y son además los que el usuario recuerda. Modelar el error con un tipo propio corrige esa asimetría y produce tres efectos que van mucho más allá de la comodidad. El primero es epistemológico: escribir el tipo obliga a preguntarse de cuántas maneras exactas puede fracasar esta operación, y esa pregunta, hecha en serio y por adelantado, suele descubrir casos que nadie había considerado y que de otro modo aparecerían meses después como incidentes. El tipo funciona como una entrevista al dominio. El segundo es temporal: la enumeración convierte cada ampliación futura del dominio en una lista de puntos que hay que revisar, generada por la máquina en el momento exacto en que se amplía. En un sistema con errores como cadenas, añadir un motivo de fallo es escribir una frase nueva y confiar en que nadie dependiera de las anteriores; en un sistema con errores tipados, añadirlo es una conversación que el compilador inicia contigo y que no puedes cerrar sin responder. El tercero es de arquitectura: separar la causa de su redacción impone una frontera limpia entre lo que el programa sabe y lo que el programa dice. Todo lo que está antes de esa frontera puede razonarse, compararse, probarse por igualdad y reintentarse; todo lo que está después es presentación y puede cambiar de idioma, de tono o de canal sin riesgo. Esta es la misma idea que sostiene la disciplina de analizar en vez de validar: no compruebes que un dato crudo es aceptable para seguir tratándolo como crudo, transfórmalo en un tipo que ya no admite ser inválido, y cuando la transformación no sea posible, di con precisión estructurada por qué. El Err no es el hueco que queda cuando algo sale mal: es la otra mitad del teorema que tu función demuestra.
- Toma un
Result String ade tu código y sustituye elStringpor un tipo de unión que enumere las causas reales; cuenta cuántas resultaron ser. - Adjunta datos a los constructores que los necesiten —el campo culpable, el código del servidor— y comprueba qué decisiones nuevas te permite tomar esa información.
- Escribe la función pura que convierte el error en mensaje y verifica que puedes traducirla entera sin tocar ninguna otra parte del programa.
- Añade un constructor nuevo al tipo y anota todos los lugares donde el compilador te obliga a decidir; contrasta con lo que habría pasado usando cadenas.
- Crea un tipo envolvente para dos capas y usa
mapErrorpara adaptar cada una, comprobando que no pierdes información en el trayecto. - Argumenta por qué comparar mensajes de error por su texto es un antipatrón y qué clase de fallo silencioso produce al traducir la aplicación.