Result y Maybe
2021-05-22
En el post anterior mencioné brevemente que mis
value objects
implementan un método estático create
que utilizo para crear
instancias sin temor a excepciones. Este método devuelve un objeto llamado
Result
que sirve para envolver el resultado de llamar al método
create
.
En este post vamos a ver cómo funciona Result
y al final vamos a
hablar un poco de Maybe
que es muy similar.
Create
Vamos directo al código, así implemento create
en mis VOs.
// amount.ts
static create(value: number, currency: string): Result<Amount, Error> {
try {
return ok(new Amount(value, currency));
} catch(error) {
return err(error);
}
}
Esté método estático se puede llamar escribiendo:
Amount.create(value, currency)
La única diferencia es que en vez de devolver una instancia de
Amount
como haría:
new Amount(value, currency)
Este método devuelve un Result
que podría ser un
Ok
o un Err
.
Veamos el contenido de create
en detalle. En el
try
intento crear una instancia de Amount
. Si no hay
problema utilizo la función ok
para crear una instancia de
Ok
que envuelva el Amount
que acabo de crear.
En el catch
atrapo el error si es que el
constructor
de Amount
lanza una excepción. Sabemos
que las excepciones que lanza el constructor
son de tipo
Error
. Si este es el caso uso la función err
para
crear una instancia de Err
que envuelva nuestro error.
Tanto Ok
como Err
son Result
. Por lo
que el tipo que devuelve este método estático siempre es Result
.
Ahora vamos a mirar Result
en detalle para entender mejor. No te
preocupes si en este punto no entiendes todo, he omitido algunos detalles que
iré revelando según vamos avanzando.
Result
Un Result
es un objeto que puede contener solo uno de dos tipos.
Ok
o Err
. El primero lo usamos para envolver
resultados positivos o sin problemas y el segundo lo usamos para envolver
errores.
La idea aquí es que en vez de lanzar una excepción cuando instanciamos un VO, devolvemos un valor que podemos inspeccionar e incluso pasar a otras partes del código sin preocupación de excepciones.
Vamos a ver como se implementa Result
en TypeScript.
// result.ts
export type Result<T, E> = Ok<T, E> | Err<T, E>;
export class Ok<T, E> {
constructor(readonly value: T) {
this.value = value;
}
isOk(): this is Ok<T, E> {
return true;
}
isErr(): this is Err<T, E> {
return !this.isOk();
}
unwrap(): T {
return this.value;
}
}
export class Err<T, E> {
constructor(readonly error: E) {
this.error = error;
}
isOk(): this is Ok<T, E> {
return false;
}
isErr(): this is Err<T, E> {
return !this.isOk();
}
unwrap(): T {
throw new Error("Called `unwrap` on an Err");
}
}
// Utility functions
export function ok<T, E>(value: T): Ok<T, E> {
return new Ok(value);
}
export function err<T, E>(err: E): Err<T, E> {
return new Err(err);
}
Vamos a romper este archivo en pedazos para entender todo lo que está pasando.
Empecemos con Ok
.
class Ok<T, E> {
constructor(readonly value: T) {
this.value = value;
}
isOk(): this is Ok<T, E> {
return true;
}
isErr(): this is Err<T, E> {
return !this.isOk();
}
unwrap(): T {
return this.value;
}
}
Como ves el constructor
recibe un valor y lo guarda en el campo
value
de la clase. Además tiene algunos métodos simples como
isOk
que siempre responde true
porque todas las
intancias de la clase Ok
están ok. También tiene lo opuesto
isErr
que obviamente hace lo contrario que isOk
.
Estos métodos los usamos para verificar que contiene un Result
.
Finalmente tenemos el método unwrap
que se encarga de sacar el
valor de dentro de nuestro Ok
. Este método lo usamos para sacar
el valor que nos interesa de dentro de un Result
que sabemos está
Ok
.
Ahora veamos la clase Err
.
class Err<T, E> {
constructor(readonly error: E) {
this.error = error;
}
isOk(): this is Ok<T, E> {
return false;
}
isErr(): this is Err<T, E> {
return !this.isOk();
}
unwrap(): T {
throw new Error("Called `unwrap` on an Err");
}
}
Err
es muy similar a Ok
. En este caso el
constructor
recibe un error como único valor. Tenemos los mismos
métodos que en Ok
porque queremos que estas dos clases tengan la
misma interface.
Algo interesante es que el método unwrap
de Err
hace
throw
de una excepción si se llama. Esto es porque cuando tenemos
un error no tenemos un valor que podamos sacar así que no hay otra cosa que
podamos hacer.
Para evitar caer en esta situación siempre tenemos que usar los métodos
isOk
o isErr
para verificar que no vamos a tener
problema cuándo llamemos unwrap
.
Generics
Lamentablemente este objeto Result
tienen que hacer uso de un
feature avanzado de TS llamado
generics
pero te puedo jurar que vale la pena entender que está pasando.
Tanto Ok
como Err
tienen unos
<T, E>
al lado del nombre de la clase. Esto lo que hace es
que le dice a TS que cuando instanciemos estas clases queremos poder decidir
que type
van a tener los valores que vamos a envolver más
adelante. En resumen es una forma de reutilizar estas clases sin tener que
saber los types de antemano.
Aquí un ejemplo.
new Ok<number, Error>(1);
Acabo de crear una instacia de Ok
que puede aceptar un
number
o un Error
aunque la clase Ok
no
usa el tipo Error
. Esto va hacer sentido más tarde.
Otro ejemplo.
new Err<number, Error>(new Error('Something wrong'));
Aquí cree un Err
y nuevamente pasé number
y
Error
como type variables a la clase para que TS sepa
los types que puede aceptar.
Result type alias
Si te fijas el objeto Result
no es una clase, en realidad es un
type alias. Aquí está la definición.
type Result<T, E> = Ok<T, E> | Err<T, E>;
Voy a tratar de traducir esto. Result
de T
y
E
es igual a Ok
de T
y E
o
Err
de T
y E
. Por esto es que ambos
Ok
y Err
aceptan dos type variables aunque
cada uno usa solo uno. Result
puede ser cualquiera de estos dos
tipos. O sea, un Ok
que envuelve un valor de tipo
T
o un Err
que envuelve un error de tipo
E
.
Utility functions
Las últimas piezas de este rompecabezas son las funciones ok
y
err
que están al final del archivo. Nota que son en minúscula a
diferencia de las clases Ok
y Err
que usan pascal
case.
Estas funciones son con lo que interactuamos la mayoría de las veces. En el
ejemplo del método create
al principio de este post se puede ver
como las usamos.
Usando Result
Regresemos al método create
del principio.
static create(value: number, currency: string): Result<Amount, Error> {
try {
return ok(new Amount(value, currency));
} catch(error) {
return err(error);
}
}
Cómo se puede ver aquí este método devulve un objeto Result
de
Amount
y Error
. En otras palabras este método puede
devolver una instancia de Ok
que contendrá un valor de tipo
Amount
o una instancia de Err
que contendrá un error
de tipo Error
.
Vuelve a leer esa dos oraciones anteriores. Creo que es importante llamar la atención sobre toda la información que estamos “codificando” dentro del type system. Estamos poniendo los types a ayudarnos a programar y detectar problemas en nuestro programa.
Sobre las funciones ok
y err
no creo que tenga que
decir mucho. Simplemente se encargan de crear una instancia de un objeto
Ok
o Err
. Las usamos cuando estamos implementando
algún método que queramos devuelva un Result
. Siempre usamos
ok
cuando la operación tiene éxito y usamos
err
cuando algo falla.
Veamos cómo se ve cuando usamos el método create
ahora que
entendemos mejor como se comporta Result
.
let priceOrErr = Amount.create(1, "USD");
if (priceOrErr.isErr()) {
console.error(priceOrError.error);
return res.send(422);
}
let price = priceOrErr.unwrap();
En la primera linea priceOrErr
es una instancia de
Result<Amount, Error>
, así que puede ser un
Ok
o un Err
y no tenemos forma de saber. En la
próxima linea hacemos el check con isErr
, si es cierto sabemos
que el contenido es un error. Pero si logramos pasar ese check, en
las lineas subsiguientes sabemos que priceOrErr
tiene que ser un
Ok
y podemos hacer unwrap
sin tenemor a ninguna
exepción.
Lo bueno de esto es que no solo nosotros sabemos que en este punto
priceOrErr
es un Ok
, también TS lo sabe y nos ayuda
con los completions. Además como le dimos mucha información a TS, el
type system sabe que es un Ok
de Amount
.
Por lo que cuando hacemos unrap
TS sabe que lo que devuelve es un
Amount
.
De la misma forma dentro del if
TS sabe que
priceOrErr
es una instancia de Err
y que contiene un
valor de tipo Error
. Gracias TypeScript.
Result en todas partes
Espero que ya veas el valor que puede añadir usar Result
en tu
programa.
El uso de Result
no tiene que limitarse a la creación de VOs.
Puedes usar un Result
en cualquier parte de tu programa donde
realizas una operación que puede fallar. O sea en muchas partes.
Por ejemplo cuando hacemos queries a la base de datos, en vez de devolver la
data directamente, envolvemos el resultado en un Result
. De esta
forma podemos verificar si está Ok
antes de continuar.
Result
nos permite usar el type system para “codificar”
información importante sobre el programa.
Combine
Además de las funciones ok
y err
uso una función
adicional que me ayuda a verificar varios Result
en un solo paso.
A esta funcion le llamo combine
porque combina todos los
Result
en un solo Result
que me dice si todos los
Result
están Ok
.
combine
recibe un Array
de Result
,
itera por ellos y si alguno es un Err
devuelve un
Err
, si todos son Ok
, devuelve un Ok
.
function combine(
results: Result<unknown, Error>[]
): Result<unknown, Error> {
for (let result of results) {
if (result.isErr()) {
return result;
}
}
return ok(results);
}
Así usamos combine
.
let priceOrErr = Amount.create(100, "USD");
let discountOrErr = Amount.create(1, "USD");
let taxOrErr = Amount.create(1, "USD");
let validationOrErr = combine([
priceOrErr,
discountOrErr,
taxOrErr
]);
if (validationOrErr.isErr()) {
console.error(validationOrErr.error);
return res.send(422);
}
let price = priceOrErr.unwrap();
let discount = discountOrErr.unwrap();
let tax = taxOrErr.unwrap();
Una vez pasamos el if
sabemos que todos los valores son
Ok
y podemos unwrap
sin problema.
Extendiendo Result
No hay ninguna regla que diga que Result
no puede tener más
métodos. Incluso si miramos la documentación de
Rust para
su tipo Result
no damos cuenta que implementa algunos método
interesantes como unwrap_or_else
.
Ese método lo podemos implementar en nuestro Result
para poder
sacar nuestro valor o un valor default si resulta que nuestro
Result
es un Err
.
let defaultAmount = new Amount(0, "USD");
let priceOrErr = Amount.create(req.body.price, "USD");
let price = priceOrErr.unwrapOr(defaultAmount);
De esta forma si el Result
de priceOrErr
es
Err
, price
será el valor de
defaultAmount
.
Hay muchas cosas interesantes que se pueden hacer con Result
, por
ejemplo podemos añadir un método map
que aplique una funcion al
valor que contenien el Result
y devuelva un nuevo
Result
con el valor modificado. Este map
debe ser lo
suficientemente inteligente como para no devolver un error si resulta que el
Result
lo que contiene es un Err
.
Con este método de map
se pueden hacer muchas cosas interesantes,
especialmente hacer cadenas de método que todos devueven un
Result
y todos esperan como input un
Result
. Pero de esto podemos hablar otro día.
Maybe
Finalmente hablaré brevemente del objeto Maybe
. Este objeto es
muy similar a Result
. Usamos Maybe
como valor para
operaciones que pueden devolver o resultado o no.
Por ejemplo imagina que tienes una funcion notJuan
que recibe una
Array
de nombres de personas y devuelva un Array
de
nombres que no son Juan. Esta función podría devolver un Array
de
resultado pero también podría devovler un Array
vació, o sea
ningún resultado.
No tengo nada en contra de los Juanes, simplemente este fue el mejor ejemplo que se me ocurrió.
Esta función la podemos implementar usando Maybe
como el
return type.
Un Maybe
puede devolver un Some
o un
None
. Devuelve Some
cuando tenemos un valor que
devolver y None
cuando no tenemos un valor que devoler. Similar a
Result
tenemos funciones some
y
none
para crear Maybe
fácilmente.
Así implementaríamos la función notJuan
.
function notJuan(names: string[]): Maybe<string> {
let validNames = names.filter((n) => n !== "juan");
if (validNames.length === 0) {
return none();
}
return some(validNames);
}
let friends = ["María", "Luis", "Juan"];
let filteredFriends = notJuan(friends);
if (filteredFriends.isNone()) {
return res.send(400);
}
let friendsExceptJuan = filteredFriends.unwrap();
En la última linea sabemos que friendsExceptJuan
contiene algunos
nombres y que ninguno es “Juan”.
Result y Maybe
En mis programas mas recientes he empezado a utilizar tanto
Result
como Maybe
. Siento que me ayudan a expresar
mejor la intención de los métodos en mi programa.
Utilizo Result
como return value para cualquier
operación que puede devolver un error y utilizo Maybe
para
cualquier operación que puede devolver datos o no.
En algunos casos me he visto usando ambos en cojunto por ejemplo puedes
imaginar un método findById
que busca en la base de datos un
record usando el id.
Esta al ser una operación de la base de datos puede fallar por muchas razones,
podemos tener un error en la conexión o quizás la petición tarda mucho y
falla. Para capturar esa información uso un Result
.
Si todo sale bien lo próximo que puede pasar es que no exista un objeto con
ese id en la base de datos y para comunicar esto uso un
Maybe
. O sea que cuando hago una operación como
findById
eso devuelve algo como esto.
Result<Maybe<User>, Error>
Admito que puede ser complicado pero pienso que de esta forma estoy codificando lo más posible dentro del type system. No quiero decir que esta es “the one true way” pero por ahora nos funciona.
Seguiremos experimentando con esto y seguirmos evolucionando pero por ahora estamos bastante satisfechos.
Referencias
Es increíble que hemos llegado hasta aquí sin usar la palabra monad. Aquí te dejo algunos enlaces que me han ayudado mucho.
- Flexible Error Handling w/ the Result Class
- Type-Safe Error Handling In TypeScript
- Result type in TypeScript
- Advanced functional programming in TypeScript: Maybe monad
- TypeScript monads: Better TypeScript Control Flow
- Monads: Type safe Option, Result, and Either types
- Get value out of your monad
Próximo
En un próximo post lo que me interesa discutir es el rol de los VOs en la validación de request en un web app y cómo podemos complementar usando otros tipos de validación con librerías como JSON Schema.