Either asíncrono con promesas en TypeScript

En un artículo anterior vimos como podemos crearnos de forma sencilla un Either en TypeScript. Es recomendable leer prevíamente ese artículo para poder tener el contexto suficiente para seguir este.

Como vimos, Either puede resultar muy útil en la gestión de errores evitando el uso de try catch del lenguaje que rompe el flujo de ejecución.

Cuando trabajamos en Typescript normalmente los procesos asíncronos se representan mediante promesas.

Si estamos realizando una computación simple asíncrona que nos devuelve una promesa no es problemático más alla de que te guste o no la promesa como tipo de retorno.

Sin embargo, el problema surge cuando queremos encadenar varias computaciónes que devuelven promesas como contenedor del Either.

Es el tema que vamos a tratar en este artículo pero empecemos por lo más simple.

Computación sincrona

En el anterior artículo, todos los ejemplos que vimos eran computaciones síncronas como este:

it("should return first error after flatmap for a left initial value", () => {
    const result: Either<ProcessFailure, number> = createApiFailureSync(404)
    const mappedResult = result.flatMap(value => Either.right(value * 2));

    mappedResult.fold(
        error => expect(error.kind).toEqual("ApiFailure"),
        () => fail("should be error")
    );
});
it("should return success mapped value after flatmap for a initial valid value", () => {
    const result = Either.right(5);
    const mappedResult = result.flatMap(value => Either.right(value * 2));

    mappedResult.fold(
        error => fail(error),
        value => expect(value).toEqual(10)
    );
});

En las computaciones sincronas no existe problema. Podemos encadenar diferentes computaciones con map, flatmap etc.. sin mayor problema.

Computacines asíncronas simples

Imagina que tenemos unos métodos asícronos de utilidad para generar eithers.

export interface ApiFailure {
    kind: "ApiFailure";
    statusCode: number;
}

export interface UnexpectedFailure {
    kind: "UnexpectedFailure";
    error: Error;
}

export type ProcessFailure = ApiFailure | UnexpectedFailure;

function createApiFailureAsync(code: number): Promise<Either<ProcessFailure, number>> {
    return new Promise(
        (resolve, _) => setTimeout(() => {
            resolve(Either.left({ kind: "ApiFailure", statusCode: code, }));
        }, 100)
    );
}

function createApiSuccessAsync(value: number): Promise<Either<ProcessFailure, number>> {
    return new Promise(
        (resolve, _) => setTimeout(() => {
            resolve(Either.right(value));
        }, 100)
    );
}

Cuando tenemos una computación simple asíncrona que nos devuelve un Either envuelto dentro de una promesa, haciendo await extraemos el Either de la promesa sin mayor problema.

it("should extract the error from either with promises", async () => {
    const result = await createApiFailureAsync(404);

    result.fold(
        error => expect((error as ApiFailure).statusCode).toEqual(404),
        _ => fail("should be error")
    );
});
it("should extract the success value from either with promises", async () => {
    const result = await createApiSuccessAsync(5);
    result.fold(
        error => fail(error),
        value => expect(value).toEqual(5)
    );
});

Computaciónes encadenadas con promesas

El problema surge cuando queremos encadenar diferentes computaciones que devuelven una promesa.

Si al ejemplo anterior le encadenamos otra computación con flatmap. Lo que estamos haciendo es hacer una operación de transformación sobre el lado right del Either de la primera computación.

De esta forma no podemos utilizar await en esta segunda computación para extraer el resultado either de la opración porque siempre devolverá una promesa la función pasada al flatmap.

const result = (await createApiSuccessAsync(5))
    .flatMap(async () => await createApiFailureAsync(404));

Typescript se queja y nos dice lo siguiente:

Type 'Promise<Either<ProcessFailure, number>>' is missing the following properties from type 'Either<ProcessFailure, unknown>': value, isLeft, isRight, fold, and 7 more.ts(2740)
Either.d.ts(8, 20): The expected type comes from the return type of this signature.

A flatmap le tenemos que pasar como argumento un Either<L,R>, no un Promise<Either<L,R>>.

Entonces una opción es no utilizar flatmap sino extraer el resultado y utilizando fold para devolver directamente el error o en caso de right ahora si hacer la segunda computación.

it("should extract the error from either with promises", async () => {
    const result1 = (await createApiSuccess(5))

    const result2 = await result1.fold(
        () => fail("Result1 should be success"),
        () => createApiFailure(404)
    );

    result2.fold(
        error => expect((error as ApiFailure).statusCode).toEqual(404),
        _ => fail("Result2 should be error")
    );
});

Pero este código no nos queda muy limpio, ni funcional.

Una de las ventajas de utilizar monadas en programación funcional es poder realizar computaciones encadenas y además tratar en caso de error en un único punto.

Imagina que necesitamos encadenar 3 o más computaciones, nos quedaría cada vez un código más complejo de entender.

it("should extract the error from either with promises", async () => {
    const result1 = (await createApiSuccess(5))

    const result2 = await result1.fold(
        () => fail("Result1 should be success"),
        () => createApiSuccess(6)
    );

    const result3 = await result2.fold(
        () => fail("Result2 should be success"),
        () => createApiFailure(6)
    );

    result3.fold(
        error => expect((error as ApiFailure).statusCode).toEqual(404),
        _ => fail("Result2 should be error")
    );
});

Creando EitherAsync

La solución que utilizo para poder realizar computaciones asíncronas encadenadas que devuelven promesas envolviendo Either es crearme una versión de Either asíncrona que en cada una de las computaciones encadenadas vaya realizando básicamente algo similar a lo que hemos hecho antes pero centralizando esta funcionalidad en nuevo tipo.

Las librerías de TypeScript existentes para programacion funcional a este tipo le llaman TaskEither o EitherAsync.

En TypeScript existen este tipo de librerías funcionales como fp-ts y purify.

Sin embargo, si en el proyecto voy a utilizar solo unos cuantos tipos, como Either, Maybe y sus versiones async, prefiero creármelos y no acoplarme a una librería de este tipo en todas las capas del proyecto.

Vamos a llamarle EitherAsync y una versión sencilla podría ser así:

export class EitherAsync<L, R> {
    private constructor(private readonly promiseValue: () => Promise<Either<L, R>>) { }

    map<T>(fn: (r: R) => T): EitherAsync<L, T> {
        return this.flatMap(async r => Either.right(fn(r)));
    }

    flatMap<T>(fn: (right: R) => Promise<Either<L, T>>): EitherAsync<L, T> {
        return new EitherAsync<L, T>(async () => {
            const value = await this.promiseValue();

            return value.fold(
                async rightValue => Either.left<L, T>(rightValue),
                rightValue => fn(rightValue),
            );
        });
    }

    mapLeft<T>(fn: (l: L) => T): EitherAsync<T, R> {
        return this.flatMapLeft(async l => Either.left(fn(l)));
    }

    flatMapLeft<T>(fn: (left: L) => Promise<Either<T, R>>): EitherAsync<T, R> {
        return new EitherAsync<T, R>(async () => {
            const value = await this.promiseValue();

            return value.fold(
                leftValue => fn(leftValue),
                async rightValue => Either.right<T, R>(rightValue)
            );
        });
    }

    run(): Promise<Either<L, R>> {
        return this.promiseValue();
    }

    static fromEither<L, R>(value: Either<L, R>): EitherAsync<L, R> {
        return new EitherAsync<L, R>(() => Promise.resolve(value));
    }

    static fromPromise<L, R>(value: Promise<Either<L, R>>): EitherAsync<L, R> {
        return new EitherAsync<L, R>(() => value);
    }
}

Ahora con este tipo EitherAsync podemos realizar computaciones encadenadas usando promesas de la misma forma que en la versión síncrona.

De esta forma tenemos un código mucho más limpio y funcional.

it("should extract the error from either with promises", async () => {
    const result1 = await EitherAsync.fromPromise(createApiSuccess(5))
        .flatMap(() => createApiSuccess(6))
        .flatMap(() => createApiFailure(404))
        .run();

    result1.fold(
        error => expect((error as ApiFailure).statusCode).toEqual(404),
        _ => fail("Result2 should be error")
    );
});

Artículos relacionados

Conclusiones

En este artículo hemos visto como podemos trabajar con Either y promesas de una forma más cómoda y funcional si nos creamos un tipo EitherAsync.

El tipo EitherAsync se encarga de ofrecernos metodos como map, flatmap etc.. que nos permite encadenar computaciones que devuelven promesas encargandose este tipo de extraer el Either de las promesas.

De esta forma el código desde donde lo usamos queda mucho más sencillo, legible y funcional.