@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()
.
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)
-
The
nothing()
function is the default unit function forNothing<T>
and returns a memoizednew Nothing(undefined)
. -
By default,
Maybe
usesundefined
for the absent value. -
The
nil()
function is the secondary unit function forNothing<T>
and returns a memoizednew Nothing(null)
. -
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.
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;
});
}
-
As
Maybe
preserves the original type, the result of thepick()
can be eithernothing()
ornil()
. SoMaybe.into()
will have a correct value on input.
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
.
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)
}
-
The argument of the
maybeDecimal
function isnumber | null | undefined
. So themaybe()
function returnsMaybe<number>
(which is eitherJust<number>
orNothing<number>
). The result of the function may also beJust
orNothing
, because we can not be sure that theto()
method will be called, even thev ⇒ v.toString(10)
returns astring
for anynumber
input. -
The argument of the
justDecimal
is always anumber
. Themaybe()
function returnsJust<number>
, because the value is always present.maybe
has a custom overload signature, and compiler also knows, thatmaybe
returnsJust<number>
.As the
v ⇒ v.toString(10)
result is always astring
, compiler also knows that the result of the whole chain remains present. And the return type can be set asJust<string>
. -
Similarly, when the value can only be
null
orundefined
, themaybe()
function returnsNothing<number>
in compile time and in runtime. And the return type of the whole chain can be set toNothing<string>
.
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
.
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');
}
-
The
new Date()
constructor creates aDate
object even for invalid inputs. -
We postpone the decision of how to handle an invalid value. By returning
Maybe<Date>
(instead ofDate|null
or throwing an error) we allow consumers of the function to make a decision that is most appropriate to their situation. -
When we record value to the database, it has to be valid. So we must throw an error when the date is invalid.
-
When we return an API response, a
null
for invalid dates is ok. -
When we try to format a date in the UI, we may prefer a readable fallback.
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)
-
The
decimal()
function returns a default value for the absent values instead of returning another absent value (or throwing an error). -
As a result, when
decimal()
is applied through theMaybe.onto()
method, it breaks the left-identity monad law. -
Applying
decimal()
throughMaybe.to()
gives the same incorrect result. -
Using the
Maybe.into()
method allows working around this issue becauseMaybe.into()
is called for all Maybe values (not only present values). -
Use the
maybeFrom()
function as a shortcut.
Note
|
Since v0.9.0,
|
-
Maybe<T>
— an abstract class, represents eitherJust<T>
orNothing<T>
. -
Just<T>
— represents a defined non-null value of typeT
. -
Nothing<T>
— represents anundefined
ornull
value.
-
maybe<T>(value: T | null | undefined): Maybe<T>
— creates an instance ofJust
when thevalue
is present, or returns a memoized instance ofNothing
with eithernull
orundefined
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 ofJust
with a given defined non-nullvalue
. A unit (return) function for theMaybe
monad. -
justFrom<T, U>(map: Unary<T | null | undefined, Present<U>>): Unary<T | null | undefined, Just<U>>
— creates a function that applies a givenmap
to a value and returns the result wrapped into aJust
. -
nothing<T>(): Nothing<Present<T>>
— returns a memoized instance ofNothing
with anundefined
value. -
nil<T>(): Nothing<Present<T>>
— returns a memoized instance ofNothing
with anull
value.
-
isMaybe<T, U>(value: Maybe<T> | U): value is Maybe<T>
— returnstrue
if a given value is aMaybe
.-
isNotMaybe<T, U>(value: Maybe<T> | U): value is U
— returnstrue
if a given value is not aMaybe
.
-
-
isJust<T, U>(value: Just<T> | U): value is Just<T>
— returnstrue
if a given value is aJust
.-
isNotJust<T, U>(value: Just<T> | U): value is U
— returnstrue
if a given value is not aJust
.
-
-
isNothing<T, U>(value: Nothing<T> | U): value is Nothing<T>
— returnstrue
if a given value is aNothing
+isNotNothing<T, U>(value: Nothing<T> | U): value is U
— returnstrue
if a given value is not aNothing
.
-
Maybe.onto<U>(flatMap: (value: T) ⇒ Maybe<Present<U>>): Maybe<Present<U>>
-
for a
Just
, applies a givenflatMap
callback to theJust.value
and returns the result; -
for a
Nothing
, ignore theflatMap
callback and returns the sameNothing
.
-
This method is similar to the mergeMap
/switchMap
operator in rxjs
and the flatMap
method in java.util.Optional
.
-
Maybe.to<U>(map: (value: T) ⇒ U | null | undefined): Maybe<U>
-
for a
Just
, applies a givenmap
callback to theJust.value
and returns the result wrapped into aMaybe
. -
for a
Nothing
, ignores themap
callback and returns the sameNothing
.
-
Maybe.to()
chainingimport { 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);
}
-
The
to
method wraps the result intomaybe
.
This method is similar to the map
operator in rxjs
and the map
method in java.util.Optional
.
-
Maybe.into<U>(reduce: (value: T | null | undefined) ⇒ U): U
— applies a givenreduce
callback to theMaybe.value
and returns the result. The purpose ofMaybe.into()
is to terminate theMaybe
and switch to a different type.
Note
|
Unlike Unlike |
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");
}
-
While passing the
Maybe.value
directly into the function is possible, theMaybe.into()
method allows to switch the chain to a different monadic type and continue the chain with that new type.
-
Maybe.pick<K extends keyof T>(property: Value<K>): Maybe<Present<T[K]>>
-
for a
Just
, returns the value of a givenproperty
ofJust.value
wrapped into aMaybe
; -
for a
Nothing
, ignores theproperty
and returns the sameNothing
.
-
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.
Maybe.pick()
for optional chainingimport { 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)
}
-
maybe(user).pick('email')
will not compile, as, in this example, theUser
type does not have anemail
property. -
When the value is
Just<T>
, and youpick
a required property, the result isJust<U>
(whereU
is the type of that property). Hence, starting amaybe
-chain withJust
is strongly recommended if the value is already present.
This method is similar to the pluck
operator in rxjs
.
-
Maybe.that(filter: Predicate<T>): Maybe<T>
-
for a
Just
, if the value matches a givenfilter
predicate, returns the sameJust
, otherwise returnsNothing
. -
for a
Nothing
, ignores thefilter
and returns itself.
-
Maybe.that()
to filter out a valueimport { 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);
}
-
Returns
Nothing
, soto()
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<U extends T>(filter: TypeGuard<T, U>): Maybe<U>
-
for a
Just
, if the value matches a givenfilter
type guard, returns the sameJust
with a narrowed-down (differentiated) type. -
for a
Nothing
, ignores thefilter
and returns itself.
-
Maybe.which()
is a filter method that requires passing a
It narrows down the result type based on the type guard.
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)
}
-
A broader
hasPresentProperty('first', 'last')
can also be used. to guarantee that these properties' values are notnull
too. But it is not required by the TS compilerstrictNullCheck
, as these properties are optional, not nullable. -
Name
type requires bothfirst
andlast
properties to be defined and not null, so without thewhich
filter (with TSstrictNullChecks
enabled), this code will not compile.
-
Maybe.when(condition: Proposition): Maybe<T>
-
for a
Just
, if a givencondition
istrue
, returns the sameJust
, otherwise returnsNothing
. -
for a
Nothing
, ignores thecondition
and returns itself.
-
Note
|
|
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 => '***');
}
-
You can use
when(() ⇒ isLog)
if you only want to run the computation when the value is present.
-
Maybe.otherwise(fallback: Value<T | null | undefined>): Maybe<T>
-
for a
Just
, ignores a givenfallback
value and returns itself. -
for a
Nothing
, returns a givenfallback
wrapped into aMaybe
.
-
Maybe.otherwise(fallback)
method allows passing a fallback value or throwing an error
if the value is absent.
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'));
}
-
otherwise
wraps the fallback value into the nextMaybe
.
-
Maybe.or(fallback: Value<T | null | undefined>): T | null | undefined
-
for a
Just
, ignores a givenfallback
value and returns theJust.value
. -
for a
Nothing
, returns the givenfallback
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.
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)
}
-
The fallback value type can be present or absent. It allows returning only
undefined
ornull
if the value is absent. -
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(procedure: (value: T) ⇒ void): Maybe<T>
-
for a
Just
, runs a givenprocedure
with theJust.value
as an argument, the returns the originalJust
. -
for a
Nothing
, ignores theprocedure
and returns itself.
-
Warning
|
The |
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 |
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];
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
:
-
unit is a left identity for bind:
let x: T; let f: (value: T) => Maybe<U>; just(x).onto(f) === f(x);
-
unit is a right identity for bind:
let ma: Maybe<T>; ma.onto(just) === ma;
-
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 |
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)
-
Maybe<T>.onto()
expects the function of typeUnary<number, Maybe<string>>
, but thedecimal
function is of typeUnary<number | null, Maybe<string>>
, so the argument type does not match. -
Applying
decimal
to a presentnumber
behaves as expected. -
When the value is absent,
onto
does not executedecimal
at all, so the result is not the same as applyingdecimal
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.
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.
The Maybe<T>
type is a functor that provides:
-
the
Maybe.to()
method as afmap
operator.
It satisfies functor laws for defined non-null T
:
-
Maybe.to()
preserves identity morphisms:let id = (value: T) => value; let value: T; maybe(value).to(id) === maybe(id(value));
-
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);