Either en TypeScript

En el anterior artículo vimos como crearnos un tipo Maybe (aka Option) en TypeScript así como las ventajas que nos paporta.

Sin embargo, Maybe tiene sentido cuando queremos modelar la presencia o ausencia de valor, pero ¿qué ocurre si en caso de ausencia de valor necesito mas información?.

Por ejemplo es habitual en aplicaciones móviles o en Single Page Applications (SPA) que el repositorio se comunique con una API.

Me puede interesar distinguir los difentes tipos de error que pueden venir de la api como 404 para la ausencia de valor, pero podríamos recibir un 400 si estamos realizando una bad request o un 500 si se produce un error interno en el servidor.

En estos contextos es interesante modelar los diferentes errores y tomar las decisiones adecuadas en cada caso.

Por este motivo Maybe no es suficiente en este caso, para estos escenarios existe en la programación funcional el tipo Either y también podemos crearnos este tipo en TypeScript.

Either

Es una mónada que representa si una computación a producido fallo o resultado satisfactorio, es una unión disjunta.

Either es un tipo genérico con dos parámetros Left o Right, por convención Left representa el tipo en la situación del error y Right en caso de resultado satisfactorio.

Un uso común, aunque no exclusivo, de Either es para la gestión de errores.

Por convención Left se usa para el caso de error y Right se usa para el éxito.

En lenguajes exclusivamente funcionales o donde se puede realizar programación funcional pura este tipo viene de serie, como sucede en Scala o Haskell.

Un implementación simple, pero suficiente para nuestro ejemplo podría ser la siguiente:


type Left<L> = { kind: "left"; leftValue: L };
type Right<R> = { kind: "right"; rightValue: R };

type EitherValue<L, R> = Left<L> | Right<R>;

export class Either<L, R> {
    private constructor(private readonly value: EitherValue<L, R>) { }

    isLeft(): boolean {
        return this.value.kind === "left";
    }
    isRight(): boolean {
        return this.value.kind === "right";
    }

    fold<T>(leftFn: (left: L) => T, rightFn: (right: R) => T): T {
        switch (this.value.kind) {
            case "left":
                return leftFn(this.value.leftValue);
            case "right":
                return rightFn(this.value.rightValue);
        }
    }

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

    flatMap<T>(fn: (right: R) => Either<L, T>): Either<L, T> {
        return this.fold(leftValue => Either.left(leftValue), rightValue => fn(rightValue));
    }

    getOrThrow(errorMessage?: string): R {
        const throwFn = () => {
            throw Error(errorMessage ? errorMessage : "An error has ocurred: " + this.value);
        };

        return this.fold(() => throwFn(), rightValue => rightValue);
    }

    getOrElse(defaultValue: R): R {
        return this.fold(() => defaultValue, someValue => someValue);
    }

    static left<L, R>(value: L) {
        return new Either<L, R>({ kind: "left", leftValue: value });
    }

    static right<L, R>(value: R) {
        return new Either<L, R>({ kind: "right", rightValue: value });
    }
}

Contiene métodos de factoria:

  • left, crea un tipo Either en caso de error.
  • right, crea un tipo Either en caso de éxito.

La clase Either encapsula el tipo que representa el valor y las funciones de utilidad.

También podríamos haberlo diseñado creando una clase por cada tipo de valor, pero con la ayuda del método fold, me gusta más tener todas las funciones de utilidad dentro de Either y jugar con los discriminated union types de Typescript para representar el valor de Maybe.

Utilizando Maybe tendremos un entorno mucho más seguro y robusto, y podremos tomar decisiones en caso de cada tipo de error.

Vamos a ver cada uno de las capacidades que no aporta.

isLeft y isRight

Cuando Either es el tipo de retorno de una función queda claro que puede la computación puede fallar y nos expone de forma clara los errores que pueden producirse para que tomemos las deciones adecuadas.

Mediante las funciones isLeft y isRight podemos saber si el tipo devuelto contiene error o no.

    it("should return isleft equal to true for a left value", () => {
        const result = Either.left({ kind: "error" });

        expect(result.isLeft()).toBeTruthy();
        expect(result.isRight()).toBeFalsy();
    });
    it("should return isRight equal to true for a right value", () => {
        const result = Either.right(5);

        expect(result.isRight()).toBeTruthy();
        expect(result.isLeft()).toBeFalsy();
    });

fold

Si prefieres un estilo de programación más funcional sin utilizar condicionales como if, a lo que te obligarían las funciones isLeft y isRight, podemos utilizar la función fold.

Recibe dos funciones, ejecutará la primera función en caso de Left y la segunda función en caso de Right, recibiendo el valor de ambos casos por parámetro.

    it("should return expected left for a left value", () => {
        const result = Either.left({ kind: "error1" });

        result.fold(
            error => expect(error.kind).toEqual("error1"),
            () => fail("should be error")
        );
    });
    it("should return expected right for a right value", () => {
        const result = Either.right(5);

        result.fold(
            error => fail(error),
            value => expect(value).toEqual(5)
        );
    });

getOrElse

La funcion getOrElse en caso de Left nos devolverá un valor por defecto pasado por parametro sino el valor de Right.

it("should getOrElse return default value for left", () => {
    const result = Either.left({ kind: "error1" });

    expect(result.getOrElse(0)).toEqual(0);
});
it("should getOrElse return a right value for right", () => {
    const result = Either.right(5);

    expect(result.getOrElse(0)).toEqual(5);
});

map

La función map, convertirá el valor de Right a través de la función que se pasa por parámetro o devolverá sin error Left si el tipo inicial era Left o el resultado de la proyección es Left.

it("should return fail mapped value for a left initial value", () => {
    const result: Either<ProcessFailure, number> = createApiFailure(404)
    const mappedResult = result.map(value => value * 2);

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

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

flatmap

Si al mapear el valor, el resultado es otro Either y no queremos tener tipos Either anidados, podemos utilizar la funcion flatmap.

En caso de Left inicial, lo devuelve sin aplicar la función de conversión sin error.

it("should return first error after flatmap for a left initial value", () => {
    const result: Either<ProcessFailure, number> = createApiFailure(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)
    );
});

Donde es útil

Todas aquellas situaciones donde tenemos la necesidad de gestionar diferentes tipos de error.

Por ejemplo imagina en una operación pueden ocurrir los siguientes errores:

  • que nos devuelva la api
  • usuario anónimo no puede realizarla
  • error inesperado

Cuando utilizamos either para la gestión de errores, estos vienen representados como el tipo Left.

Si los definimos a su vez como indiscriminated union types a su vez en typescript.

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

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

export interface AnonymousUserFailure {
    kind: "AnonymousUserFailure";
}

export type ProcessFailure = ApiFailure | UnexpectedFailure | AnonymousUserFailure

Vamos a poder hacer uso de pattern matching con checking exhaustivo.

const result: Either<SyncFailure, syncResult> = syncProducts(products)

const handleFailure = (failure: SyncFailure) => {
    switch (failure.kind) {
        case "AnonymousUserFailure":
            return "please make a login to can sync products"
        case "ApiFailure":
            return failure.message
        case "UnexpectedFailure":
            return "An unexpected error has ocurred"
    }
}

result.fold(
    failure => console.log(handleFailure(failure)),
    result => console.log({ result })
);

Artículos relacionados

Conclusiones

Como hemos visto either nos aporta una forma más segura de para la gestion de errores.

Nos ahorra todo tipo de comprobaciones de error pudiendo encadenar proyecciones seguras, proporcionandonos un entorno robusto.

Ilustración de undraw.co