Skip to content

Latest commit

 

History

History
802 lines (639 loc) · 24.1 KB

index.adoc

File metadata and controls

802 lines (639 loc) · 24.1 KB

Maybe monad

@perfective/common/maybe package provides an Option type implementation. It is inspired by the Haskell Maybe monad and satisfies the monad laws.

Maybe type simplifies handling of the absent (null/undefined) values and provides methods that are called only when the value is present. It allows the creation of chained calls similar to Promise.then() and RxJS pipe().

Handling null and undefined values

In JavaScript, two types represent an "absence" of value: undefined and null. So the dichotomies like Just | Nothing or Some | None do not fit precisely and require additional logic to cover all the cases: T | null | undefined. For example, when you create a new Maybe<T> with a null value, it has to maintain the value as null and should not change it to undefined.

Maybe<T> maintains the original value.
import { maybe, nil, nothing } from '@perfective/common/maybe';
import { isGreaterThan } from '@perfective/common/number';

maybe(3.14).value === 3.14;

maybe(undefined).value === undefined;
nothing().value === undefined; // (1)
maybe(3.14)
    .that(isGreaterThan(4))
    .value === undefined; // (2)

maybe(null).value === null;
nil().value === null; // (3)
maybe(null)
    .that(isGreaterThan(4))
    .value === null; // (4)
  1. The nothing() function is the default unit function for Nothing<T> and returns a memoized new Nothing(undefined).

  2. By default, Maybe uses undefined for the absent value.

  3. The nil() function is the secondary unit function for Nothing<T> and returns a memoized new Nothing(null).

  4. If the original value is null, null will be returned as an absent value after all transformations.

It is not always desired to have both undefined and null excluded. For example, consider you have a field in an API that is null when there is no data, but the field is not returned if a user does not have access to it. In this case, you may prefer to fall back from null to a default value but to throw an error for undefined.

To cover these cases, @perfective/common/maybe provides the Maybe.into() method: it allows to apply a function that handles either T, null, or undefined value.

The original absent value (null or undefined) is maintained through transformations.
import { isNull, isUndefined } from '@perfective/common';
import { panic } from '@perfective/common/error';
import { just } from '@perfective/common/maybe';

interface ApiResponse {
    username?: string | null;
}

function username(response: ApiResponse) {
    return just(response)
        .pick('username')
        .into((username: string | null | undefined) => { // (1)
            if (isUndefined(username)) {
                return panic('Unknown username');
            }
            if (isNull(username)) {
                return 'anonymous';
            }
            return username;
        });
}
  1. As Maybe preserves the original type, the result of the pick() can be either nothing() or nil(). So Maybe.into() will have a correct value on input.

Preserving the monad type

This package strictly preserves the type of the monad. For example, if you have a Just type and apply a function that returns a present value, then the result will also be of type Just.

Difference between Maybe, Just and Nothing contexts.
function maybeDecimal(value: number | null | undefined): Maybe<string> {
    return maybe(value).to(v => v.toString(10)); // (1)
}

function justDecimal(value: number): Just<string> {
    return maybe(value).to(v => v.toString(10)); // (2)
}

function nothingDecimal(value: null | undefined): Nothing<string> {
    return maybe<number>(value).to(a => a.toString(10)); // (3)
}
  1. The argument of the maybeDecimal function is number | null | undefined. So the maybe() function returns Maybe<number> (which is either Just<number> or Nothing<number>). The result of the function may also be Just or Nothing, because we can not be sure that the to() method will be called, even the v ⇒ v.toString(10) returns a string for any number input.

  2. The argument of the justDecimal is always a number. The maybe() function returns Just<number>, because the value is always present. maybe has a custom overload signature, and compiler also knows, that maybe returns Just<number>.

    As the v ⇒ v.toString(10) result is always a string, compiler also knows that the result of the whole chain remains present. And the return type can be set as Just<string>.

  3. Similarly, when the value can only be null or undefined, the maybe() function returns Nothing<number> in compile time and in runtime. And the return type of the whole chain can be set to Nothing<string>.

Using onto() and to() methods

Both Maybe.onto() and Maybe.to() methods apply a given function only when the value is present. But onto requires the function to return the next Maybe instance, while to will wrap the returned value into Maybe.

When writing functions that use Maybe chaining, the best practice is to return the packed value (as Maybe, Just, or Nothing). This allows a consumer of the function to decide how they want to unpack it or to keep it as Maybe for the next chain.

When you have a function of non-Maybe types, then you have to use Maybe.to.

For example, consider you are writing a function to parse a date.
function isValidDate(date: Date): boolean {
    return date.toString() !== 'Invalid Date'; (1)
}

function parsedDate(input: string): Maybe<Date> { (2)
    const date = new Date(input);
    if (isValidDate(date)) {
        return just(date);
    }
    return nothing();
}

interface BlogPost {
    createdAt: string;
}

function dbDate(input: BlogPost): Date { (3)
    return just(input)
        .pick('createdAt')
        .onto(parsedDate)
        .or(panic('Invalid "Created At" Date'));
}

function jsonDate(input: BlogPost): string|null { (4)
    return just(input)
        .pick('createAt')
        .onto(parsedDate)
        .or(null);
}

function formattedCreatedAt(input: BlogPost): string { (5)
    return just(input)
        .pick('createdAt')
        .onto(parsedDate)
        .or('Unknown date');
}
  1. The new Date() constructor creates a Date object even for invalid inputs.

  2. We postpone the decision of how to handle an invalid value. By returning Maybe<Date> (instead of Date|null or throwing an error) we allow consumers of the function to make a decision that is most appropriate to their situation.

  3. When we record value to the database, it has to be valid. So we must throw an error when the date is invalid.

  4. When we return an API response, a null for invalid dates is ok.

  5. When we try to format a date in the UI, we may prefer a readable fallback.

Using the into() method with the maybeFrom() function

The Maybe.into() method allows reducing a Maybe instance into a different type. It applies the argument function for present and absent values. In combination with the maybeFrom() function, it allows to apply functions with custom handling of absent values and return a new Maybe instance.

import { isAbsent } from '@perfective/common';
import { just, maybe, maybeFrom } from '@perfective/common/maybe';

function decimal(value: number | null | undefined): string {
    if (isAbsent(value)) {
        return '0'; // (1)
    }
    return value.toString(10);
}

maybe(null).onto(x => maybe(decimal(x))) != just(decimal(null)); // (2)
maybe(null).to(decimal) != just(decimal(null)); // (3)

maybe(null).into(x => maybe(decimal(x)) == just(decimal(null)) // (4)
maybe(null).into(maybeFrom(decimal)) == just(decimal(null)) // (5)
  1. The decimal() function returns a default value for the absent values instead of returning another absent value (or throwing an error).

  2. As a result, when decimal() is applied through the Maybe.onto() method, it breaks the left-identity monad law.

  3. Applying decimal() through Maybe.to() gives the same incorrect result.

  4. Using the Maybe.into() method allows working around this issue because Maybe.into() is called for all Maybe values (not only present values).

  5. Use the maybeFrom() function as a shortcut.

Note

Since v0.9.0, Maybe.into(maybeFrom) replaced the Maybe.lift(map) method.

Reference

Types

  • Maybe<T> — an abstract class, represents either Just<T> or Nothing<T>.

  • Just<T> — represents a defined non-null value of type T.

  • Nothing<T> — represents an undefined or null value.

Functions

  • maybe<T>(value: T | null | undefined): Maybe<T> — creates an instance of Just when the value is present, or returns a memoized instance of Nothing with either null or undefined value.

  • maybeFrom<T, U>(map: Unary<T | null | undefined, U | null | undefined>): Unary<T | null | undefined, Maybe<U>>

  • just<T>(value: Present<T>): Just<T> — creates an instance of Just with a given defined non-null value. A unit (return) function for the Maybe monad.

  • justFrom<T, U>(map: Unary<T | null | undefined, Present<U>>): Unary<T | null | undefined, Just<U>> — creates a function that applies a given map to a value and returns the result wrapped into a Just.

  • nothing<T>(): Nothing<Present<T>> — returns a memoized instance of Nothing with an undefined value.

  • nil<T>(): Nothing<Present<T>> — returns a memoized instance of Nothing with a null value.

Type Guards

  • isMaybe<T, U>(value: Maybe<T> | U): value is Maybe<T> — returns true if a given value is a Maybe.

    • isNotMaybe<T, U>(value: Maybe<T> | U): value is U — returns true if a given value is not a Maybe.

  • isJust<T, U>(value: Just<T> | U): value is Just<T> — returns true if a given value is a Just.

    • isNotJust<T, U>(value: Just<T> | U): value is U — returns true if a given value is not a Just.

  • isNothing<T, U>(value: Nothing<T> | U): value is Nothing<T> — returns true if a given value is a Nothing + isNotNothing<T, U>(value: Nothing<T> | U): value is U — returns true if a given value is not a Nothing.

Maybe.onto()

  • Maybe.onto<U>(flatMap: (value: T) ⇒ Maybe<Present<U>>): Maybe<Present<U>>

    • for a Just, applies a given flatMap callback to the Just.value and returns the result;

    • for a Nothing, ignore the flatMap callback and returns the same Nothing.

This method is similar to the mergeMap/switchMap operator in rxjs and the flatMap method in java.util.Optional.

Maybe.to()

  • Maybe.to<U>(map: (value: T) ⇒ U | null | undefined): Maybe<U>

    • for a Just, applies a given map callback to the Just.value and returns the result wrapped into a Maybe.

    • for a Nothing, ignores the map callback and returns the same Nothing.

Using Maybe.to() chaining
import { Maybe, maybe } from '@perfective/common/maybe';
import { lowercase } from '@perfective/common/string';

interface Name {
    first: string;
    last: string;
}

interface User {
    name?: Name;
}

function nameOutput(name: Name): string { // (1)
    return `${name.first} ${name.last}`;
}

function usernameOutput(user?: User): Maybe<string> {
    return maybe(user)
        .pick('name')
        .to(nameOutput)
        .to(lowercase);
}
  1. The to method wraps the result into maybe.

This method is similar to the map operator in rxjs and the map method in java.util.Optional.

Maybe.into()

  • Maybe.into<U>(reduce: (value: T | null | undefined) ⇒ U): U — applies a given reduce callback to the Maybe.value and returns the result. The purpose of Maybe.into() is to terminate the Maybe and switch to a different type.

Note

Unlike Maybe.onto() and Maybe.to(), the Maybe.into() method is called even if the Maybe.value is absent.

Unlike Maybe.or() and Maybe.otherwise(), the Maybe.into() method is called even if the Maybe.value is present.

Using Maybe.into()
import { Maybe, maybe } from '@perfective/common/maybe';
import { isPresent } from '@perfective/common';

function usernameRequest(userId: number | null | undefined): Promise<string> {
    if (isPresent(userId)) {
        return Promise.resolve({ userId });
    }
    return Promise.reject("UserId is missing");
}

function username(userId: Maybe<number>): Promise<string> {
    return userId.into(usernameRequest) // === usernameRequest(userId.value)
        .then(response => response.username) // (1)
        .catch(() => "Unknown");
}
  1. While passing the Maybe.value directly into the function is possible, the Maybe.into() method allows to switch the chain to a different monadic type and continue the chain with that new type.

Maybe.pick()

  • Maybe.pick<K extends keyof T>(property: Value<K>): Maybe<Present<T[K]>>

    • for a Just, returns the value of a given property of Just.value wrapped into a Maybe;

    • for a Nothing, ignores the property and returns the same Nothing.

Note

Only properties that are defined on the value type are allowed.

It is similar to the optional chaining introduced in TypeScript 3.7 but does not generate excessive JS code for each null and undefined check in the chain.

Using Maybe.pick() for optional chaining
import { panic } from '@perfective/common/error';
import { maybe } from '@perfective/common/maybe';

interface Name {
    first?: string;
    last?: string;
}

interface User {
    id: number;
    name?: Name;
}

function firstName(user?: User): string {
    return maybe(user).pick('name').pick('first').or(panic('Unknown first name')); // (1)
}

function userId(user: User): number {
    return just(user).pick('id').value; // (2)
}
  1. maybe(user).pick('email') will not compile, as, in this example, the User type does not have an email property.

  2. When the value is Just<T>, and you pick a required property, the result is Just<U> (where U is the type of that property). Hence, starting a maybe-chain with Just is strongly recommended if the value is already present.

This method is similar to the pluck operator in rxjs.

Maybe.that()

  • Maybe.that(filter: Predicate<T>): Maybe<T>

    • for a Just, if the value matches a given filter predicate, returns the same Just, otherwise returns Nothing.

    • for a Nothing, ignores the filter and returns itself.

Using Maybe.that() to filter out a value
import { isNot } from '@perfective/common/function';
import { Maybe, just } from '@perfective/common/maybe';

function quotient(dividend: number, divisor: number): Maybe<number> {
    return just(divisor)
        .that(isNot(0)) // (1)
        .to(divisor => dividend / divisor);
}
  1. Returns Nothing, so to() will not be running its function.

This method is similar to the filter operator in rxjs and the filter method in java.util.Optional.

Maybe.which()

  • Maybe.which<U extends T>(filter: TypeGuard<T, U>): Maybe<U>

    • for a Just, if the value matches a given filter type guard, returns the same Just with a narrowed-down (differentiated) type.

    • for a Nothing, ignores the filter and returns itself.

Maybe.which() is a filter method that requires passing a

It narrows down the result type based on the type guard.

Using Maybe.which() to filter out values with absent properties.
import { Maybe, just } from '@perfective/common/maybe';
import { hasDefinedProperty } from '@perfective/common/object';

interface Name {
    first: string;
    last: string;
}

interface Username {
    first?: string;
    middle?: string;
    last?: string;
}

function nameOutput(name: Name): string {
    return `${name.first} ${name.last}`;
}

function usernameOutput(user: User): Maybe<string> {
    return just(user)
        .which(hasDefinedProperty('first', 'last')) // (1)
        .to(nameOutput); // (2)
}
  1. A broader hasPresentProperty('first', 'last') can also be used. to guarantee that these properties' values are not null too. But it is not required by the TS compiler strictNullCheck, as these properties are optional, not nullable.

  2. Name type requires both first and last properties to be defined and not null, so without the which filter (with TS strictNullChecks enabled), this code will not compile.

Maybe.when()

  • Maybe.when(condition: Proposition): Maybe<T>

    • for a Just, if a given condition is true, returns the same Just, otherwise returns Nothing.

    • for a Nothing, ignores the condition and returns itself.

Note

Maybe.when() should be used for better readability instead of passing a nullary function into the Maybe.that().

Using Maybe.when() to filter out values based on a global condition.
import { just } from '@perfective/common/maybe';

function tokenLogOutput(token: string, isLog: boolean): Maybe<string> {
    return just(token)
        .when(isLog) // (1)
        .to(token => '***');
}
  1. You can use when(() ⇒ isLog) if you only want to run the computation when the value is present.

Maybe.otherwise()

  • Maybe.otherwise(fallback: Value<T | null | undefined>): Maybe<T>

    • for a Just, ignores a given fallback value and returns itself.

    • for a Nothing, returns a given fallback wrapped into a Maybe.

Maybe.otherwise(fallback) method allows passing a fallback value or throwing an error if the value is absent.

Using Maybe.otherwise() to continue the chain after the fallback.
import { panic } from '@perfective/common/error';
import { isNot } from '@perfective/common/function';
import { maybe } from '@perfective/common/maybe';

function range(min?: number, max?: number): number {
    return maybe(min)
        .otherwise(max) // (1)
        .that(isNot(0))
        .otherwise(panic('Invalid range'));
}
  1. otherwise wraps the fallback value into the next Maybe.

Maybe.or()

  • Maybe.or(fallback: Value<T | null | undefined>): T | null | undefined

    • for a Just, ignores a given fallback value and returns the Just.value.

    • for a Nothing, returns the given fallback value.

The Maybe.or(fallback) method allows getting the present monad value and providing a fallback value or throwing an error when the value is missing.

Using Maybe.or()
import { panic } from '@perfective/common/error';
import { maybe } from '@perfective/common/maybe';

interface Name {
    first: string;
    last: string;
}

interface User {
    name?: Name;
}

function nameOutput(name?: Name): string {
    return maybe(name)
        .to(name => `${name.first} ${name.last}`)
        .or('Unknown name'); // (1)
}

function userOutput(user?: User): string {
    return maybe(user)
        .pick('name')
        .to(nameOutput)
        .or(panic('Undefined user')); // (2)
}
  1. The fallback value type can be present or absent. It allows returning only undefined or null if the value is absent.

  2. Using panic or any other function that throws an error when called allows guaranteeing a present value is returned.

This method is similar to the orElse, orElseGet, and orElseThrow methods in java.util.Optional.

Maybe.through()

  • Maybe.through(procedure: (value: T) ⇒ void): Maybe<T>

    • for a Just, runs a given procedure with the Just.value as an argument, the returns the original Just.

    • for a Nothing, ignores the procedure and returns itself.

Warning

The Maybe.through() does not check if the given procedure mutates the present value.

import { maybe } from '@perfective/common/maybe';

function logError(error?: Error): Error|undefined {
    return maybe(error)
        .through(console.error);
}
Note

This method is similar to the tap operator in rxjs and ifPresent method in java.util.Optional.

Lifting functions

Each method has a corresponding lifting function to be used in the Array.prototype.map (or any other mapping method or operator).

import { Maybe, just, nil, nothing, or } from '@perfective/common/maybe';

const numbers: Maybe<number>[] = [
    just(2.71),
    just(3.14),
    nothing<number>(),
    nil<number>(),
];

numbers.map(or(0)) === [2.71, 3.14, 0, 0];

Type classes

Monad

The Maybe<T> type is a monad that provides:

  • the Maybe.onto() method as a bind operator (>>=);

  • the just() constructor as a unit (return) function.

It satisfies the three monad laws for defined non-null T:

  1. unit is a left identity for bind:

    let x: T;
    let f: (value: T) => Maybe<U>;
    
    just(x).onto(f) === f(x);
  2. unit is a right identity for bind:

    let ma: Maybe<T>;
    
    ma.onto(just) === ma;
  3. bind is associative:

    let ma: Maybe<T>;
    let f: (value: T) => Maybe<U>;
    let g: (value: U) => Maybe<V>;
    
    ma.onto(a => f(a).onto(g)) === ma.onto(f).onto(g)
Warning

If you have a flatMap function with custom handling for null or undefined values, you may break the left-identity and the associativity monad laws.

Custom handling of null with Maybe<T>.onto() breaking the left-identity law.
import { isNull } from '@perfective/common';
import { Just, just, nil } from '@perfective/common/maybe';

function decimal(value: number | null): Just<string> { // (1)
    if (isNull(value)) {
        return just('0');
    }
    return just(value.toString(10));
}

just(3.14).onto(decimal) == decimal(3.14); // (2)
nil().onto(decimal) != decimal(null); // (3)
  1. Maybe<T>.onto() expects the function of type Unary<number, Maybe<string>>, but the decimal function is of type Unary<number | null, Maybe<string>>, so the argument type does not match.

  2. Applying decimal to a present number behaves as expected.

  3. When the value is absent, onto does not execute decimal at all, so the result is not the same as applying decimal directly.

If you have to use custom handling of null/undefined, you should use the Maybe.into() method that passed null and undefined as into the callback.

Custom handling of null and undefined
import { isAbsent } from '@perfective/common';
import { Just, just, nothing, nil } from '@perfective/common/maybe';

function decimal(value: number | null | undefined): Just<string> {
    if (isAbsent(value)) {
        return just('0');
    }
    return just(value.toString(10));
}

just(3.14).onto(decimal) == decimal(3.14); // === just('3.14')
just(3.14).into(decimal) == decimal(3.14); // === just('3.14')

nothing().onto(decimal) == nothing(); // != decimal(undefined);
nothing().into(decimal) == decimal(undefined); // === just('0')

nil().onto(decimal) == nil(); // != decimal(null);
nil().into(decimal) == decimal(null); // === just('0')

For the (legacy) functions (written prior to using Maybe) that handle/return null/undefined, you should use Maybe.map() or Maybe.lift() methods.

Functor

The Maybe<T> type is a functor that provides:

  • the Maybe.to() method as a fmap operator.

It satisfies functor laws for defined non-null T:

  1. Maybe.to() preserves identity morphisms:

    let id = (value: T) => value;
    let value: T;
    
    maybe(value).to(id) === maybe(id(value));
  2. Maybe.to() preserves composition of morphisms:

    let f: (value: U) => V;
    let g: (value: T) => U;
    let value: T;
    
    maybe(value).to(v => f(g(v))) === maybe(value).to(g).to(f);