Acciones como llamar a una API o validar datos ingresados por el usuario son muy comunes en el desarrollo y son ejemplos de funciones que pueden dar un resultado correcto o fallar. Por lo general para controlarlo en javascript (y otros lenguajes) solemos usar y crear excepciones simples.
Parecen la forma más simple de controlar los errores que pueda tener la aplicación o programa que estamos desarrollando. Sin embargo a medida que los proyectos y los equipos van creciendo, empiezan a surgir escenarios que nos exigen algo más. En equipos grandes por ejemplo, aporta mucho que las funciones sean explicitas al indicar si pueden fallar, para permitir a nuestros compañeros anticipar y gestionar esos errores.
Ser explicito con los tipos de "error" que puede tener una acción no solo ayudara a que el desarrollo sea mucho más sencillo. También servirá como documentación de reglas de negocio.
En javascript disponemos de algunas técnicas para lograrlo. Para no quedarnos solo en la teoría, consideremos un ejemplo de la vida real: en una aplicación de reservas de hoteles, un usuario reserva una habitación, recibe un código y después debe pagarla. Al momento de realizar el pago, el API de momento nos puede mostrar estos 3 escenarios de "error":
El código de reserva no existe.
El pago es rechazado.
El código de reserva ya no es válido.
Como estamos haciendo una aplicación para el usuario además de esos dos casos deberías tener en cuenta uno extra:
No hay conexión a internet. (o el servicio no esta disponible)
Esta función puede ser llamada desde distintos componentes de nuestra aplicación y en caso falle debe mostrarse el error al usuario.
Teniendo este ejemplo en mente repacemos algunas posibilidades de como manejarlo
Errores Personalizados
Las excepciones son comunes en muchos lenguajes, y JavaScript incluye algunas predefinidas (como SyntaxError). Al momento de lidiar con posibles errores una buena practica es ser especifico y personalizarlos.
En js para crear una Excepcion basta con usar la palabra reservada throw
seguido de lo que queramos (si así como lo lees).
function makeError() {
throw "Error string"
}
Js es muy permisivo en ese sentido, sin embargo se considerar mala práctica hacer un throw
de algo que no descienda de la clase Error
que viene en js.
class MyError extends Error {
constructor(message) {
super(message);
this.name = "MyError";
}
}
function makeError() {
throw MyError("")
}
Como se puede ver la clase error viene con una propiedad que nos permite describir con mayor detalle porque estamos creando la excepción (y nosotros podemos agregarle las propiedades que deseemos).
Volviendo al problema que planteamos como ejemplo. Aplicando los errores personalizados podemos tener control sobre que hacer en cada escenario.
class CodeInvalid extends Error {/*...*/}
class PaymentRejected extends Error {/*...*/}
class CodeExpired extends Error {/*...*/}
class RedError extends Error {/*...*/}
async function apiTransaction(code) {
try {
const r = await fetch(/* Url */)
const response = await r.json()
....
if (response.message === "Code Invalid") {
throw new CodeInvalid()
}
// Agregar más casos
return response
} catch (e) {
throw new RedError()
}
}
async function payReservation(code) {
try {
const payData = await apiTransaction(code)
showResultToUser(payData)
return
} catch (e) {
if (e instanceOf CodeInvalid) {
showMessageToCodeInvalid()
return
} else if (e instanceOf PaymentRejected) {
// ...
} else if (e instanceOf CodeExpired) {
// ...
} else (e instanceOf RedError) {
// ...
}
throw e
}
}
Con esto no solo ganamos el poder tener encaminar el flujo de diferentes formas sino también distinguimos entre errores internos del sistema (por ejemplo el error de alguna dependencia interna que usemos dentro de payReservation
etc.) de lo que representan reglas de negocio.
Esta es una muy buena opción, y cumple con nuestro objetivo de controlar el flujo según cada caso y si alguien ve la función sabe porque puede fallar. Con eso ya ganamos bastante, sin embargo hay algunas cosas que debemos considerar con este enfoque.
Las excepciones de una función si no son controladas dentro de un catch pasan al "nivel superior". Para poner un ejemplo si tienes la función A, que llama a B este asu vez llama a C y C lanza una excepción no controlada esta pasara a B, si B no la controla entonces sigue hasta A etc. Esto dependiendo de cual sea tu caso puede ser una buena noticia. Para declarar un posible error por regla de negocio podría terminar siendo tedioso, ya que habría que revisar todas las funciones para conocer posibles excepciones.
Otro punto a tener en cuenta es la developer expirence tan valorada hoy. Aunque herramientas como JsDoc permite describir agregar que un método puede tener una excepción el editor no lo reconoce. Typescript por otro lado no reconoce esas excepciones al momento de escribir o llamar a la función.
[] **Rendimiento:* lanzar y controlar excepciones tienen un impacto (mínimo) en el rendimiento (algo similar al uso del break). Aunque en entornos como una app ese impacto es casi nulo.
Encapsulando Errores con un valor Result (o Either)
Si vemos el caso anterior las excepciones que creamos no son por errores "irreparables", más bien son parte de las reglas de negocio. Cuando las excepciones se vuelven lo común dejan de ser realmente casos excepcionales y fueron pensados para eso. En lugar de lanzar excepciones, podemos encapsular el estado "éxito" y "error" en un solo objeto como el siguiente.
const responseModel = {
isOk: true,
data: {},
errorName: ''
}
Si usamos typescript (o los d.ts para usar jsdoc) podríamos definir los tipos así.
type Response<T,N extends String> = OkResponse<T> | ErrorResponse<N>
interface OkResponse<T> {
isOk: true,
data: T
}
interface ErrorResponse<N> {
isOk: false,
errorName: N
}
Aplicándolo a nuestro ejemplo. Si ahora nuestra función payReservation
devuelve este objeto en ves de una excepción, mediante JSDoc o Typescript podemos especificar que tipo de resultados podemos tomar (de ahora en adelante pondré los ejemplo en typescript para simplificar).
Esto ayuda al equipo a saber de antemano qué errores puede devolver la función.
type ApiTransactionResponse = Response<PaymentData, 'PaymentPartnerError'|'PaymentRejected'|'CodeInvalid'|'RedError'>
function apiTransaction(code: number) : Promise<ApiTransactionResponse> {
//...
}
async function payReservation(code) {
const payData = await apiTransaction(code)
if (payData.isOk) {
showResultToUser(payData)
} else if (payData.errorName === 'CodeInvalid') {
showMessageToCodeInvalid()
return
} else if (payData.errorName === 'PaymentRejected') {
// ...
} else if (payData.errorName === 'CodeInvalid') {
// ...
} else (payData.errorName === 'RedError') {
// ...
}
}
Aplicando esto obtenemos las ventajas del enfoque con excepciones y además, en tiempo de desarrollo, el editor mostrará información sobre los diferentes casos de "error" que se pueden producir.
De hecho este tipo de concepto lleva tiempo rondando en la programación, en muchos lenguajes funcionales no tienen excepciones y usan este tipo de datos para su error, hoy muchos lenguajes lo implementan. En Rust y Dart por ejemplo existe la clase Result nativamente, la librería Arrow de Kotlin tambien la agrega.
Existe cierto estandar sobre como usar e implementar el Result
, para que nuestro código sea más comprensible para nuevos desarrolladores podemos apoyarnos en esas convenciones.
Result
puede representar un estado de éxito o error de manera exclusiva (no puede ser ambos a la vez), y permite trabajar con ambos estados sin lanzar excepciones.
class Result<R, E extends string> () {
#isOk = true
get value() {
return this.#value
}
get error() {
return this.#error
}
get isOk() {
return this.#isOk
}
get isError() {
return this.#isError
}
constructor(value, isOk : boolean) {
if ( isOk) {
this.#value = value
} else {
this.#error = value
}
this.#isOk = isOk
}
static Ok<T>(value: T) : Result<T, never> {
return new Result(value, true)
}
static Error<E>(error: E) : Result<never, E> {
return new Result(value, false)
}
}
El ejemplo hace uso de clases pero no es necesario, también hay implementaciones más sencillas, yo tengo una que suelo llevarme a los proyectos donde creo que puedo necesitarla, dejo el link por si quieren verla y/o usarla.
Si lo dejamos así no más no ganamos mucho en relación al objeto que creamos antes. Por eso es bueno saber que suele implementar más métodos
Por ejemplo un método getOrElse
para retornar valores por defecto en caso de error.
class Result<R, E> () {
//...
function getOrElse(defValue: R) {
if (this.isOk) {
return this.#value
}
return defValue
}
//...
}
y fold para manejar el flujo de éxito/error de forma funcional.
class Result<R, E> () {
//...
function fold(fnOk: (arg: R) => any, fnError: (arg: E) => any) {
if (this.isOk) {
return fnOk(this.value)
}
return fnError(this.error)
}
//...
}
También es posible que encuentres información sobre manejo de errores usando
Either
.Result
vendría a ser unEither
con mayor contexto,Either
tiene el valor derecha (right) e izquierda (left). Como en inglés right también se usa para decir que algo esta bien, suele llevar el valor correcto mientras que a la izquierda queda el error, pero no necesariamente es así,result
en cambio es más explicito en relación a donde esta el valor correcto y el error.
Aplicandolo a nuestro ejemplo, payReservation quedaría algo así:
type ApiTransactionResponse = Result<PaymentData, 'CodeInvalid'|'PaymentRejected'|'CodeExpired'|'RedError'>
function apiTransaction(code: number) : Promise<ApiTransactionResponse> {
//...
return new Result.OK(paymentData)
// or ...
return new Result.Error(descriptionError)
}
async function payReservation(code) {
const payDataResult = await apiTransaction(code)
return payDataResult.fold(
(payData) => { return showResultToUser(payData) },
(e) => {
if (e === 'CodeInvalid') {
return showMessageToCodeInvalid()
} else if (e === 'PaymentRejected') {
//...
} else if (e === 'CodeExpired') {
//...
} else if (e === 'RedError') {
//...
}
}
)
}
[*] Un buena práctica sería establecer un tipo de dato base para los errores, en el ejemplo use string, pero lo ideal sería que tuviese una forma más definida como un objeto con nombre al que se le pueda ir agregando más datos por ejemplo, puedes ver ejemplo de ello aquí
A simple vista puede parece que agregar la clase es más sobre-ingenieria que otra cosa. Pero Result
es un concepto muy utilizado, mantener las convenciones ayuda a que tu equipo pueda captarlo más rápido y es una forma eficaz de robustecer tu manejo de errores.
Con esta opción describimos explícitamente que "errores" puede tener una función, podemos controlar el flujo de nuestra aplicación de acuerdo al tipo de error, obtenemos ayuda del editor mientras convocamos la función y por último dejamos las excepciones para casos de error en sistema.
A pesar de estas ventajas, también hay que tener en consideración algunos puntos antes de implementarlo. Como mencione al inicio de esta sección Result
es nativo en muchos lenguajes pero en JS no, por lo tanto al implementarlo estamos añadiendo una abstracción extra. Otro punto a tener en cuenta es el escenario en el que estamos, no todas las aplicaciones necesitarán tanto control (En una landing page de alguna campaña publicitaria por ejemplo no vería sentido en implementar el Result
). Conviene evaluar si puedes aprovechar todo el potencial o solo será peso extra.
En resumen, manejar los errores no sólo mejora la calidad del código, sino también la colaboración en equipo, al proporcionar un flujo de trabajo predecible y bien documentado. Result
y las excepciones personalizadas son herramientas que, bien utilizadas, contribuyen a un código más mantenible y robusto.
Un extra en TS
En TypeScript, podemos sacar un beneficio adicional a Result para asegurarnos de que todos los casos de error estén cubiertos:
function typeCheck(_:never) {}
async function payReservation(code) {
const payDataResult = await apiTransaction(code)
return payDataResult.fold(
(payData) => { return showResultToUser(payData) },
(e) => {
if (e === 'CodeInvalid') {
return showMessageToPartnerError()
} elseif (e === 'PaymentRejected') {
//...
} elseif (e === 'CodeExpired') {
//...
} elseif (e === 'RedError') {
//...
} else {
typeCheck(e)
}
}
)
}
La función de typeCheck
tiene como objetivo validar que todos los posibles valores de e
sean controlados dentro de los if/else if
.
En este repo dejo un poco más de detalle.
Top comments (0)