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.

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.