Manejo de errores con Javascript
Alternativa a excepciones para manejar consistentemente los errores de tu aplicación
En el artículo de esta semana profundizamos un poco en los tipos de manejo de errores que encontramos en Javascript, ya sea de forma nativa o a través de técnicas avanzadas.
El manejo de errores es una de esas cosas de las que nos solemos olvidar cuando se diseñan aplicaciones frontend y que se termina recurriendo a una solución rápida o apoyarse directamente en las posibilidades que el lenguaje nos ofrece, sin preguntarnos si quiera si esa solución es la que más se adapta a nuestras circunstancias o no.
Antes de zambullirnos en las diferentes técnicas de las que contamos para manejar errores, vamos a empezar viendo que tipo de errores podemos tener.
Errores síncronos o asíncronos
Vemos el siguiente código:
function mutate() {
obj.name = '1';
return obj;
}
Este código puede fallar si al ejecutar la función mutate la variable obj no está definida como un objeto de forma global. Por tanto, generará un ReferenceError, una excepción que provoca el intérprete de Javascript cuando se intenta usar una variable que o bien no ha sido definida o bien no se ha inicializado.
En cambio, si miramos este otro código:
function getUser() {
return fetch('/user');
}
Aunque explícitamente no esté definido ningún error, nosotros que somos programadoras avispadas nos percatamos que hay muchas cosas que pueden ir mal ahí. De entrada, puede que cuando intentemos hacer esa petición haya algún problema con nuestra conexión, y por tanto obtengamos un NetworkError. Seguidamente podría haber algún problema con el recurso http, desde que no exista hasta que no tengamos permisos para acceder a el.
La diferencia entre este error y el anterior es el momento en el que se produce. En el caso de nuestra función mutate, el error ocurre en el momento de la evaluación, de forma síncrona y en con getUser ocurre a través de una utilidad externa de forma asíncrona (que escapa a nuestro control).
Por tanto, según el productor y en qué momento se generen los errores, estos se pueden clasificar en dos tipos: síncronos y asíncronos. Los primeros se producen por la ejecución de código síncrono y los segundos por código que se ejecuta fuera del flujo normal del programa y de los que rara vez somos responsables.
Errores tipados y no tipados
Una función dada solo debería de devolver un tipo. Este es un principio clave en diseño de software que nos puede ahorrar más de un dolor de cabeza y evita que los clientes hagan un mal uso de las funciones.
Es decir, que, si una función recibe números y devuelve otro número, debería de hacerlo tanto en el happy path como en los casos extremos (edge cases) porque si no se hace difícil para un cliente el saber como utilizar la función.
Por ejemplo, echemos un vistazo a la función part:
function part(a, b) {
return a / b;
}
Este código puede devolver o un número o una indeterminación. Concretamente si b = 0, ya que la división entre 0 no está determinada. Es decir, que si intensemos buscar un número que restado sobre a = 5 cero veces hasta llegar a 0, nos encontraremos restando infinitamente: 7-0=0,7-0=0,7-0=0…
La división entera entre dos números se define como: hallar el número de veces que necesitamos restar el divisor al dividendo para obtener 0. Si el divisor es 0, el número de veces que tenemos que hacer la resta para obtener 0, está indeterminado.
Esta indeterminación de la que estamos hablando en Javascript se representa por el número Infinity, el cual es una instancia del tipo Number. Esto quiere decir que, aunque la función part puede devolver valores no determinados estos están narrowed (acotados, del concepto en inglés narrowing types) al tipo Number:
Infinity.__proto__ === Number.prototype // true
Por tanto, este conocido caso extremo de la división entera, en Javascript está contemplada bajo el mismo tipo, lo que hace para el cliente la función part siempre devuelva un número. Incluso si desde los clientes forzamos usos más extremos de la función part:
part() // NaN
part(null, null) // NaN
part("hello", "world") // NaN
NaN al igual que Infinity es una instancia de Number:
NaN.__proto__ === Number.prototype // true
Te puede gustar más o menos este comportamiento, pero asegura que tanto el mal o el buen uso del operador de división siempre retorne el mismo tipo.
Esto ocurre con todos los operadores, salvo en aquellos que estén sobrecargados para varios tipos (como el operador de suma +, que es válido para sumar números como para concatenar cadenas de texto) donde entra en juego lo que se conoce como coercion y provoca muchos de esos comportamientos rarunos que habrás visto por ahí.
En contraposición a esto, vamos a ver otro ejemplo:
function getUserById(id) {
if (!id) return null;
return data.users[id]; // suponemos que data.users existe
}
Si tuviéramos que escribir la documentación para esta función, nos daríamos cuenta de que getUserById devuelve dos tipos de valores. O bien un User en caso de que proveamos un id o null en caso contrario.
Esto tiene varios problemas:
No queda claro para el cliente en qué casos se devuelven null o User. Teniendo que confiar en que la documentación (de existir) esté actualizada.
Obliga siempre a hacer una comprobación para cerciorarse de que lo que devuelve getUserById sea un usuario válido y no un tipo que además puede confundirse por un objeto en según que casos.
De olvidarse el cliente de hacer las comprobaciones de tipo pertinentes, podría dar lugar a petes silenciosos.
En resumen, con la función part nos encontrábamos que los errores estaban tipados (representados dentro del mismo tipo Number) y con la función getUserById vemos que los errores y valores válidos están representados como tipos distintos obligando al cliente a hacer comprobaciones.
Excepciones
Ahora que hemos visto los tipos de errores que podemos tener en nuestras aplicaciones, vamos a ver cómo gestionarlos intentando solucionar los problemas que hemos comentado cuando intentamos manejar errores usando tipos primitivos.
La primera técnica que seguro se nos viene a la mente, son las excepciones. Una excepción es un objeto que aúna en un mismo tipo los errores síncronos y asíncronos y que son producidos por APIs o nosotros mismos en tiempo de ejecución.
Veamos por ejemplo esta función que genera una excepción:
function part(a, b) {
if (b === 0) throw new Error('B cannot be 0');
return a / b;
}
Ahora la función part devuelve un número o genera una excepción con el mensaje.
Si ahora el cliente quiere gestionar este error tendría que hacer algo como esto:
try {
part(1,0);
} catch(err) {
console.log(err.message);
}
O si este error ocurre como parte de un proceso asíncrono:
Promise.resolve().then(() =>
part(1,0)).catch(err => console.log(err.message))
Este enfoque, aunque es interesante porque nos permite gestionar errores tanto asíncronos como síncronos y nos ofrece un API nativa en el lenguaje, presenta varios problemas:
Como proveedores estamos obligados a derivar la clase Error para crear errores específicos que den contexto a los clientes del error que está sucediendo. Esto nos obliga a usar clases que, si bien pueden tener ventajas en ciertos puntos, la mayoría de las veces comprometen la legibilidad sin aportar demasiadas ventajas.
Las excepciones actúan como si fueran un go-to. Es decir, rompen la ejecución normal del programa y nos llevan a otros puntos del mismo. Esto es una pega muy grande cuando estamos debuggeando puesto que perdemos todo el contexto de ejecución.
Hay que recordar siempre poner un try/catch en algún punto del programa, sino provocará excepciones no controladas que hagan explotar toda la aplicación, incluso si la excepción generada no es severa (no es lo mismo una excepción de división entre 0 que puede subsanarse con un valor por defecto que un acceso denegado a algún recurso)
No nos libran del problema expuesto arriba: una función pasa no solo a no devolver un único tipo, sino a generar errores que se controlan de forma diferente según el tipo de ejecución.
Estos puntos hacen que las excepciones sean poco recomendables en la mayoría de casos, pero es importante mencionar que incluso si no queremos usarlas es muy posible que nos veamos obligados si utilizamos APIs que generen excepciones (por ejemplo, el API del DOM tiende muchas a veces a lanzar DOMExceptions).
Gestionando errores con tipos predecibles
Bueno aparte de los inconvenientes comentados de las excepciones que eclipsan por mucho las ventajas, personalmente no me gustan mucho. La sintaxis de control de errores en bloques try/catch me parece ruda y demasiado imperativa.
Mi forma preferida es la de encapsular los resultados y errores en un contexto (o monada) que siempre se comporte de la misma manera, es decir que da igual si dentro hay un valor o un error, el API para poder trabajar con el debe de ser consistente.
Esto es lo mismo que ocurría cuando hablábamos del tipo Maybe. Esta estructura almacena o bien un valor o bien nada, ese nada sirve para representar bajo un mismo tipo la ausencia de valor (que Javascript se utilizan los tipos primitivos null o undefined).
Pero al contrario que ocurría con Maybe donde no hacíamos ningún esfuerzo por representar el valor de error, en nuestro caso necesitamos una estructura que no solo represente valores válidos sino también los inválidos.
A esta estructura normalmente se la denomina Result.
La gestión de errores por Result es muy común en lenguajes puramente funcionales como Elm pero también está presente en lenguajes como Rust.
El uso de esta estructura viene automáticamente con estos beneficios:
Las funciones pueden devolver un único tipo ya que no es necesario representar valor y error de formas distintas. Así el valor de retorno será ahora Result<T,E>.
No obliga a los clientes a gestionar el error. Al trabajar con estructuras monádicas se puede ir propagando el tipo en todo el programa hasta el final.
No tenemos los problemas que se les asociaban a las excepciones.
Aunque también es cierto que vienen con algunas desventajas que son interesantes conocer:
Suelen tener más carga cognitiva al inicio por lo que pueden ser más difíciles de entender para programadores juniors.
Hay que estar acostumbrado a razonar sobre monadas para evitar caer en un tipo de programación demasiado imperativa y sustentada en condicionales del tipo “si es es un ok entonces esto, si es un fail esto otro” lo cual es un poco anti-patrón.
Debemos siempre tener claro los valores que puede tomar un programa, para evitar volver a usar tipos débiles como null/undefined como valores por defecto.
Trabajando con Result
Result es una estructura que puede imponer un poco de entrada, pero cuando nos acostumbramos al tipo de programación que nos habilita, todo se vuelve más sencillo.
Para los siguientes ejemplos voy a estar usando el paquete @nidstang/result el cuál creo que propone un API muy limpia y útil además de obligarnos siempre a pasar por interfaces en el caso de querer sacar el valor (creo, humildad aparte porque lo he hecho yo XD).
Creando un Result
Hay dos formas de crear un Result, concretamente a través de dos factorías:
Ok<T>
Fail<E>
Ambas constituyen el tipo Result<T,E> a través de una especie de union type.
Tanto Ok como Fail reciben un valor, el tipo del mismo puede ser cualquiera.
Ahora vamos a modificar la función part que vimos antes para en lugar de devolver números (o lanzar excepciones) devuelva siempre un Result, vayan las cosas bien o mal:
import { Ok, Fail } from '@nidstang/result';
/**
* @param {number} a
* @param {number} b
*
* @returns {Result<number, string>}
*/
function part(a, b) {
if (b === 0) return Fail('B must not be 0');
return Ok(a / b);
}
Recuerda que tanto Ok como Fail devuelven el tipo Result<T,E>. En este caso como ya sabemos los tipos de T y E los ponemos en la documentación.
Modificando el valor del Result
Ahora digamos que somos un cliente que va a hacer uso de esa función part y que con el resultado lo que queremos hacer es una transformación, por ejemplo, duplicar el valor obtenido.
Toda la idea de las monadas es que se nos proporcionan un contexto para trabajar con un valor, pero no podemos acceder directamente a el, sino que debemos hacerlo a través de abstracciones. Por suerte para nosotros, Result es un Functor y por tanto tiene un método map que nos permite transformar el valor que hay dentro:
const res = part(2,2);
res.map(x => x * 2); // Result(2)
Y ¿qué devuelve map? Pues otro Result claro, solo que esta vez el valor que tenga dentro estará transformado.
Hasta aquí muy bien, pero ¿en qué se diferencia esto con un Maybe? La respuesta es sencilla: Si el Result que devuelve part fuese un fail la función map no haría nada puesto que solo transforma valores ok:
const res = part(1,0);
res.map(x => x * 2) // Result('B must not be 0');
Ahora, ¿qué ocurre si quiero transformar el valor de un fail? Pues que la librería nos provee de otro método interesante llamado mapErr. Este método de forma similar a lo que haría map, solo se ejecutará cuando el Result sea un fail.
const res = part(1,0);
res.mapErr(err => `Error: ${err}`) // Result('Error: B must not be 0');
Sacando el valor del Result
La idea es que todas las operaciones del ciclo de vida de nuestro programa se hagan utilizando el Result pero es cierto que, en el momento de enviar el valor a otro lado, renderizarlo o imprimirlo en algún sitio, tenerlo mentido dentro de un contexto nos es poco útil.
Para ello, la librería nos ofrece una forma de sacar el valor, pero no sin antes proveer un valor por defecto en el caso de que lo que haya dentro sea un fail:
const value = res.unwrapOr(0);
En este caso si res es un fail, value será 0. Y si es un ok, el value tendrá el valor que tuviese dentro el Result.
Solo podemos sacar el valor de un ok. Los valore de los fail han de ser gestionados mediante mapErr.
Gestionando errores asíncronos
Aunque Result está más pensado para gestionar errores que se producen de forma sincrona, la librería @nidstang/result nos ofrece un método para transformar la monada a una promesa y poder así comportarse como si fuera asíncrona:
const res = part(1,1);
res.resolve() // Promise(1)
Esto generará una promesa resolved si el result era un ok o una promesa rejected si el result era fail.
Sin embargo, esto solo nos vale cuando queremos que este error se gestione en un pipeline asíncrono, pero ¿qué ocurre si queremos generar una excepción asíncrona desde el primer momento? Lo mejor en este caso es englobar el result dentro de una promesa, así tendríamos la potencia de las promesas para toda la gestión asíncrona sumado al API del result para trabajar con los valores:
const res = Promise.resolve(Ok(1));
No obstante, esto viene con una carga mayor a la hora de gestionar dos tipos de datos que en última instancia sirven para lo mismo, solo que una gestiona errores asíncronos y el otro los que se producen de forma asíncrona.
Conclusión
Bien, hemos dado un repaso a los tipos de manejos de errores y al final hemos dado una forma de trabajar con ellos mediante tipos monadicos.
La idea es que las funciones devuelvan siempre lo mismo (sean puras) y que podamos trabajar con errores y valores de la misma forma, reduciendo la carga cognitiva de otras soluciones como pueden ser las excepciones y obligando siempre al programador a obrar en consecuencia.
Si queremos sacar el valor, tenemos que explícitamente proporcionar un valor por defecto en el caso de que estemos ante un fallo. Un enfoque mucho más seguro que el de que el programa explote en su conjunto porque nos olvidamos de poner un try/catch.
En futuros artículos hablaremos sobre como poder aprovecharnos de tipos como Result para no representar estados que no sean válidos sin perder ninguna de las ventajas que hemos visto hoy aquí. Además, seguiremos profundizando en el enfoque asíncrono.
Nada más por hoy :)
Excelente!
Gracias por compartir