Maybe (aka option) en TypeScript

La popularidad de TypeScript en desarrollo web esta cada vez más en auge, hace poco Dropbox en este artículo explicaba la migración a TypeScript que han hecho desde 2017 hasta la actualidad, donde tienen todo el código ya migrado.

Utilizo TypeScript por defecto en los proyectos ReacJS y me siento realmente cómodo con este lenguaje.

Hace tiempo vimos en un artículo el uso de Option en Kotlin.

Me gusta aplicar conceptos de programación funcional, como tipos de datos algebraicos tipo sum, Option o Maybe para representar la posibilidad de ausencia de valor también en TypeScript.

Las características del lenguaje lo permiten y el código queda más claro que lanzando errores.

Existen librerías de programación funcional tambien para typescript como fp-ts y purify que traen muchos tipos funcionales.

Si solo vamos a utilizar Maybe y algún tipo más, yo prefiero crearme esos tipos y no traerme un librería entera de la que solo voy a utilizar una pequeña parte.

Maybe

El tipo Option o Maybe, es una mónada que representa un valor opcional. Las instancias de Maybe serán de tipo Some o None.

Maybe es un tipo genérico con un parámetro que representa el tipo en la situación de presencia de valor.

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

Este tipo de objeto se suele utilizar para representar la presencia o ausencia de valor.

Se suele utilizar en aquellos escenarios donde es posible que no exista un resultado o se produzca un caso excepcional.

Existen diferentes formas conseguir tener un tipo Maybe en TypeScript.

Vemos un ejemplo sencillo pero suficiente para la mayoria de los proyectos.


type None = { kind: "none" };
type Some<Data> = { kind: "some"; someValue: Data };

type MaybeValue<Data> = None | Some<Data>;

export class Maybe<Data> {
    private constructor(private readonly value: MaybeValue<Data>) { }

    isDefined(): boolean {
        return this.value.kind === "some";
    }

    isEmpty(): boolean {
        return this.value.kind === "none";
    }

    fold<T>(leftFn: () => T, rightFn: (someValue: Data) => T): T {
        switch (this.value.kind) {
            case "none":
                return leftFn();
            case "some":
                return rightFn(this.value.someValue);
        }
    }

    get(): Data {
        return this.getOrThrow();
    }

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


    flatMap<T>(f: (wrapped: Data) => Maybe<T>): Maybe<T> {
        return this.fold(
            () => Maybe.none(),
            someValue => f(someValue));
    }

    map<T>(f: (wrapped: Data) => T): Maybe<T> {
        return this.flatMap(data => Maybe.fromValue(f(data)));
    }

    getOrThrow(errorMessage?: string): Data {
        const throwFn = () => {
            throw Error(errorMessage ? errorMessage : "Value is empty")
        };

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

    static some<Data>(value: Data): Maybe<Data> {
        if (!value) {
            throw Error("Provided value must not be empty");
        }
        return new Maybe({ kind: "some", someValue: value });
    }

    static none<Data>(): Maybe<Data> {
        return new Maybe({ kind: "none" });
    }

    static fromValue<Data>(value: Data | undefined | null): Maybe<Data> {
        return !value ? Maybe.none() : Maybe.some(value);
    }
}

Contiene métodos de factoria:

  • none, crea un tipo Maybe en caso de ausencia de valor
  • some, crea un tipo Maybe en caso de que exista un valor valor
  • fromValue, es una fachada de los otros dos, haciendo la verificación de si debe llamar a none o a some.

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

También podríamos haberlo diseñado creando una clase derivada 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 Maybe 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.

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

isDefined y isEmpty

Cuando Maybe es el tipo de retorno de una función queda claro que puede que no exista valor.

Mediante las funciones isDefined y isEmpty podemos saber si el tipo devuelto contiene valor o no.

it("should return isEmpty equal to true for a undefined value", () => {
    const maybe = Maybe.fromValue(undefined);

    expect(maybe.isDefined()).toBeFalsy();
    expect(maybe.isEmpty()).toBeTruthy();
});
it("should return isEmpty equal to true for a null value", () => {
    const maybe = Maybe.fromValue(null);

    expect(maybe.isDefined()).toBeFalsy();
    expect(maybe.isEmpty()).toBeTruthy();
});
it("should return isDefined equal to true for a valid value", () => {
    const maybe = Maybe.fromValue(5);

    expect(maybe.isDefined()).toBeTruthy();
    expect(maybe.isEmpty()).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 isDefined y isEmpty, podemos utilizar la función fold.

Recibe dos funciones, ejecutará la primera función en caso de no existir valor y la segunda función si existe valor, recibiendo este por parámetro.

it("should return none for a undefined value", () => {
    const maybe = Maybe.fromValue(undefined);

    maybe.fold(
        () => expect(maybe.isEmpty()).toBeTruthy(),
        () => fail("Should return none for a undefined value")
    );
});
it("should return none for a null value", () => {
    const maybe = Maybe.fromValue(null);

    maybe.fold(
        () => expect(maybe.isEmpty()).toBeTruthy(),
        () => fail("Should return none for a null value")
    );
});
it("should return some for a valid value", () => {
    const maybe = Maybe.fromValue(5);

    maybe.fold(
        () => fail("Should return expected mapped value after flatmap"),
        value => expect(value).toEqual(5)
    );
});

get

Con la función get podemos extraer el valor que contiene y en caso de no tener valor lanzará un Error.

it("should get throw an error for undefined value", () => {
    const maybe = Maybe.fromValue(undefined);

    expect(maybe.get).toThrow();
});
it("should get throw an error for null value", () => {
    const maybe = Maybe.fromValue(null);

    expect(maybe.get).toThrow();
});
it("should get return the valid value", () => {
    const maybe = Maybe.fromValue(5);

    expect(maybe.get()).toEqual(5);
});

getOrElse

La funcion getOrElse es similar a get, la diferencia es que en caso de ausencia de valor nos devolverá un valor por defecto pasado por parametro.

it("should getOrElse return default value for undefined value", () => {
    const maybe = Maybe.fromValue(undefined);

    expect(maybe.getOrElse(0)).toEqual(0);
});
it("should getOrElse return default value for null value", () => {
    const maybe = Maybe.fromValue(null);

    expect(maybe.getOrElse(0)).toEqual(0);
});
it("should getOrElse return a value for valid value", () => {
    const maybe = Maybe.fromValue(5);

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

map

La función map, convertirá el valor a través de la función que se pasa por parámetro o devolverá sin error none si hay ausencia de valor.

it("should return none after map a undefined value", () => {
    const maybe = Maybe.fromValue(undefined);
    const mappedMaybe = maybe.map(number => number * 2);

    mappedMaybe.fold(
        () => expect(mappedMaybe.isEmpty()).toBeTruthy(),
        () => fail("Should return none after map a undefined value")
    );
});
it("should return none after map a null value", () => {
    const maybe = Maybe.fromValue(null);
    const mappedMaybe = maybe.map(number => number * 2);

    mappedMaybe.fold(
        () => expect(mappedMaybe.isEmpty()).toBeTruthy(),
        () => fail("Should return none after map a null value")
    );
});
it("should return some after map a valid value", () => {
    const maybe = Maybe.fromValue(5);
    const mappedMaybe = maybe.map(number => number * 2);

    mappedMaybe.fold(
        () => fail("Should return expected mapped value after flatmap"),
        value => expect(value).toEqual(10)
    );
});

flatmap

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

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

it("should return none after flatmap a null initial value", () => {
    const maybe = Maybe.fromValue(undefined);
    const mappedMaybe = maybe.flatMap(value => Maybe.fromValue(value * 2));

    mappedMaybe.fold(
        () => expect(mappedMaybe.isEmpty()).toBeTruthy(),
        () => fail("Should return none after map a null value")
    );
});
it("should return initial none after flatmap for a null initial value", () => {
    const maybe = Maybe.fromValue(null);
    const mappedMaybe = maybe.flatMap(value => Maybe.fromValue(value * 2));

    mappedMaybe.fold(
        () => expect(mappedMaybe.isEmpty()).toBeTruthy(),
        () => fail("Should return none after map a null value")
    );
});
it("should return expected mapped value after flatmap for a initial valid value", () => {
    const maybe = Maybe.fromValue(5);
    const mappedMaybe = maybe.flatMap(value => Maybe.fromValue(value * 2));

    mappedMaybe.fold(
        () => fail("Should return expected mapped value after flatmap"),
        value => expect(value).toEqual(10)
    );
});

Donde es útil

Todas aquellas situaciones donde exista la posibilidad de ausencia de valor van a ser escenarios adecuados, por ejemplo:

  • Cuando solicitamos un recurso por su identificador, en este escenario es posible que no exista un recurso para el identificador solicitado.
  • Cuando necesitamos realizar computaciones adicionales en base a una anterior y una o varias pueden tener ausencia de valor, en este escenario trabajariamos con el map o flatmap, con la seguridad de que si todas las computaciones son exitosas nos devolverá some con el valor final y si existe ausencia de valor en alguna de ellas, no realiza las siguientes y nos devuelve none.

Artículos relacionados

Conclusiones

Como hemos visto maybe nos aporta una forma más segura de trabajar en situaciones donde puede existir ausencia de valor.

Nos ahorra todo tipo de comprobaciones de ausencia de valor, ya sea null o undefined, proporcionandonos un entorno robusto.

Pero en ocasiones Maybe se puede quedar corto y necesitaremos de otro tipo de dato funcional, pero eso lo veremos en el próximo artículo.

Ilustración de undraw.co