ts-maybe-type v1.1.0
Object-oriented Maybe type in TypeScript
The Maybe type, a.k.a. Option in Fβ―, Optional in Java 8, is conceptually similar to the type { value?: T }. Except that its value is properly encapsulated i.e. not directly accessible. Instead, the Maybe type offers higher-level but still intuitive behaviors that enforces the quality of the code on the client side.
π‘ It's really a well designed type. It's due to its mathematical foundations (functor and monad) and to functional programming principles. But don't worry ! It's simple and intuitive enough to be used without this background.
π Its main benefit is to help having a codebase more robust (null free) and more structured (separation of data flow and control flow).
π The design is object-oriented: the type exposes methods like map and filter, so that they can be chained, offering simpler syntax and idiomatic usage in TypeScript than using external functions, at least as long as TypeScript will not have operators like |> (pipe right) and >> (compose right).
Introduction
TL;DR
- Much more than a simple
nullsubstitute! - Easy to grasp due to similarity with
arrayand its methodsfilter,map,flatMap - Robust and safe: in all cases, no runtime error by design (no
null,undefinedbillion dollars mistake) - Ease splitting a business operation to improve the domain expression and simplify the code
Absence of value: null is still not ideal
Maybe is a generic type to model the absence of value.
π― We have
strictNullCheckoption to keep us safe withnull. Why not usingnullin this case β
strictNullCheck is a great improvement: every function that return either a value or null (or undefined, implicitly included for now) must indicate it in its signature: T | null. For instance, find method of an array items: T[] has return type T | undefined: it returns undefined when no item has been found.
Still, null is in itself problematic as it can lead to:
- Branches in the client code, in various forms:
- Null Guard :
if (o != null) { useNonNull(o); } - Syntactical sugar:
- Ternary operator:
o ? o.key : null - Falsy/Nullish coalescing :
find(...) || defaultValueor with??(Cβ―, Ts 3.7) - Optional chaining (a.k.a. Null Propagation) :
o?.k(Cβ―, Ts 3.7)
- Ternary operator:
- Null Guard :
- Cumulative negative effects:
if/elseblocks chained or, worst, nested (arrow anti-pattern)- β Raises cyclomatic complexity
- β Looses readability
Principle of Maybe
Maybe<T> is a "box" that can contain:
- either some value of a given type
T, - or none (a.k.a. nothing, empty).
π This is how Maybe type models the absence of value.
Partial operation & Maybe type as return type
Maybe is useful as a return type of a function defining a partial operation, i.e. a function that computes a value but may not do it for some input values.
Example: inverting a number is not possible when it's zero. It's a partial operation.
- In JavaScript,
1/0causes no errors returning a edge case valueInfinity, but not indicated in the signature (stillnumber). - We can return
Maybe<number>to indicate that the invert computation is a partial operation.- When
n !== 0, instead of returning1/n, we wrap this value in theMaybeinstance, using the factory functionMaybe.some(1/n) - When
n === 0, instead of returningnullorundefinedorNaN, we return an emptyMaybeinstance, using the factory functionMaybe.none()
- When
- 2 styles of implementation are possible:
// 1. Explicit return type
const invert = (n: number): Maybe<number> =>
// -------------------- βοΈ
n ? Maybe.some(1 / n)
: Maybe.none();
// 2. Inferred return type
const invert = (n: number) =>
n ? Maybe.some(1 / n)
: Maybe.none<number>();
// ------ βοΈOpacity
When there's no value in the box, we don't want to throw an error or to return undefined. We don't want to put the burden upfront, on the client code side that has to rely on the Tester/Doer pattern (if (hasValue) use(value)) for its own safety. We better provide intrinsic safety by design.
π There's neither get value() nor get hasValue() in the Maybe type.
The box is fully opaque, encapsulating its optional inner value, but lets us:
- Perform some filtering/mapping operation on the optional value in the box
- Match exhaustively both cases to converge to a final "value" or do a final "IO" operation
- @see
match
- @see
- Unwrap the optional value if we give a default value when there's none
- @see
valueOrDefault,valueOrGet
- @see
Intrinsic control flow
Context: we are dealing with a business operation that is partial. It's complex enough so that we have split it in sub operations, some being partial too.
We want the client code to be responsible only of the data flow because it's the purest expression of the domain modeling. We don't want any control flow regarding whether some operation returned no value. This logic is delegated to the box itself.
This control flow is expressed through:
- Array-like methods that can be chained:
map,flatMap,filter traversefunction for "mass processing"
βοΈ They respects functional programming principles that make code much safer because deterministic:
- Immutability :
Maybeinstance are immutable. If it has to change its value or toggle its status, it will do it in a new instance and return it. No other part in the codebase can interact / mutate the current object. - Purity : as long as the mapping/filtering function are pure, the overall operation will be pure = side-effect free = no mutation, no change out of scope => repeatability: same inputs will produce same outputs.
Explicit data flow
Since map, flatMap, filter methods can be chained, we can split an partial operation into sub operations, some of them being partial too.
βοΈ Advantages:
- Each sub operation is simpler to understand and to test.
- Express the happy path, the nominal case where every sub operations return a value.
- Dealing with absence of value: only once, at the end
- With
valueOrDefaultorvalueOrGetto get the final value, unwrapped or defaulted- For instance a
stringwith the formatted value or an error message
- For instance a
- With
match()for a final operation producing a value (that can be of another type) or not (see Angular example oftraversefunction)
- With
Methods
map method
(a.k.a
lift,SelectLINQ)
- Aim: executing a mapping operation which is total (= not partial)
- Expressed as a function with signature
(value: T) -> U
- Expressed as a function with signature
- Schema:
Maybe<T>βmap(operation)βMaybe<U> - Case count: 2 β tracks some and none not connected:
Input Operation Output
1. some(x) ββββΊ map( x -> y ) ββββΊ some(y)
2. none()Β ββββΊ map( .... ) ββββΊ none()Example:
const maybeThree = Maybe.some(3);
// Returned by a previous partial operation
const double = (n: number) => n * 2;
// Next operation (total)
const result = maybeThree.map(double);
// Equivalent of `Maybe.some(6)`flatMap method
(a.k.a
andThen,bind,SelectManyLINQ)
- Aim: executing a mapping operation which is partial
- Expressed as a function with signature
(value: T) -> Maybe<U>
- Expressed as a function with signature
- Why not use
map?- Because we will have nested box
Maybe<Maybe<U>>which is not practical.
- Because we will have nested box
- Solution: flatten the result
- Case count: 3 β "tracks" some and none are connected:
Input Operation Output
1a. some(x) ββ¬ββΊ flatMap( x -> some(y) ) ββββΊ some(y)
1b. βββΊ flatMap( x -> none() ) ββ
2. none()Β ββββΊ flatMap( .... ) ββ΄ββΊ none()π "Bowling gutter" effect: once in the gutter, no way to get out of it.
Example: compute the average price of the orders of a client -> 2 partial operations to combine:
- Getting the client orders
- Computing the average order price, impossible when there's no orders
type Order = { price: number };
declare function getOrders: (clientId: number) => Maybe<Order[]>;
// Return `none` when client is unknown
declare function sum: (numbers: number[]) => number;const computeAveragePrice = (orders: Order[]): Maybe<number> =>
orders.length
? Maybe.some(sum(orders.map(x => x.price)) / orders.length)
: Maybe.none();
const computeAverageOrderPrice = (clientId: number) =>
getOrders(clientId)
.flatMap(computeAveragePrice);filter method
(a.k.a
WhereLINQ)
map,flatMapβ mapping of valeurfilterβ skip a value when it does not satisfy a condition, evaluated by the given predicate- Signature 1:
filter(predicate: (value: T) => boolean): Maybe<T> - Signature 2:
filter<S extends T>(guard: (value: T) => value is S): Maybe<S>
- Signature 1:
- Cases: 3 - tracks some and none connected - same "gutter effect" as
flatMap:
Input Predicate Output
1a. some(x) ββ¬ββΊ filter( x -> true ) ββββΊ some(y)
1b. βββΊ filter( x -> false ) ββ
2. none()Β ββββΊ filter( .... ) ββ΄ββΊ none()fillWhenNone method
- Signature:
fillWhenNone(defaultValue: T): Maybe<T> - Description:
fillWhenNonemethod has an opposite purpose compared tofilter: populating some value when it is missing.
Input Value Output
1a. some(x) ββ¬ββΊ fillWhenNone(β¦) ββ¬ββΊ some(x)
2. none()Β ββββΊ fillWhenNone(x) ββExample:
// Simulate "OR" operator between 2 optional numbers
function combineResults(results1: Maybe<number>, results2: Maybe<number>): Maybe<number> {
return results1.match({
some: x => results2.map(y => x + y).fillWhenNone(x),
none: () => results2,
});
}
combineResults(Maybe.none<number>(), Maybe.none<number>()); // β Maybe.none()
combineResults(Maybe.some(1), Maybe.none<number>()); // β Maybe.some(1)
combineResults(Maybe.none<number>(), Maybe.some(2)); // β Maybe.some(2)
combineResults(Maybe.some(1), Maybe.some(2)); // β Maybe.some(3)βοΈ Notes:
- It reverts the "gutter" effect.
- It's different from
valueOrDefaultbecause the former keeps the value in a box while the later unwraps it.
match method - Pseudo pattern matching objet-oriented style
This method mimics Fβ― pattern matching of the Option union type. It's a variation of the Visitor design pattern, match being the equivalent of accept(visitor).
- Signature:
match<U>(visitor: { some: (value: T) => U, none: () => U }): U - Description: exhaustive pattern matching of the 2 cases (some value vs none), converging to a final unwrapped type
U(that can bevoid).
Example #1:
const threeOrUndefined = [1, 2, 3, 4].find(x => x === 3);
// β Type: `number | undefined`
const maybeThree = Maybe.ofNullable(threeOrUndefined);
// β Type: `Maybe<number>`
// In Fβ―
const message = maybeThree.match({ // match maybeThree with
some: x => `the value is ${x}`, // | Some x -> sprintfn "the value is %A" x
none: () => `the value is None`, // | None -> sprintfn "the value is None"
});Example #2:
const average = (total: number, count: number): Maybe<number> =>
Maybe.some(count)
.filter(x => x > 0)
.map(x => total / x);
const testAverage = (total: number, count: number): void => {
const message = average(total, count).match({
some: x => `given positive count (${count}), the average is ${x}`,
none: () => `given count 0, the average is None`,
});
console.log(message);
}
testAverage(100, 0); // > given count 0, the average is None
testAverage(100, 25); // > given positive count (25), the average is 4βοΈ Note: match({ some, none }) is equivalent to chaining map(some).valueOrGet(none).
valueOrDefault method
(a.k.a
defaultIfNone,orElseJavaOptional,FirstOrDefaultLINQ)
- Signature:
valueOrDefault(defaultValue: T): T - Description: unwrap the value if there is some or return the given
defaultValue. - Example:
declare function tryGenerateNumber(): Maybe<number>;
const result =
tryGenerateNumber()
.map(square) // >= 0
.flatMap(tryInvert)
.valueOrDefault(-1); // < 0 expresses the "failure", the absence of value, like `Array::indexOf` doesβοΈ Note: valueOrDefault method is conceptually similar to nullish coalescing operator ?? but without increasing the cyclomatic complexity:
- With nullish value:
null ?? -1β-1 - With
Maybetype:Maybe.none<number>().valueOrDefault(-1)β-1
valueOrGet method
(a.k.a
orElseGetJavaOptional)
- Signature:
valueOrGet(getDefaultValue: () => T): T - Description: unwrap the value if there is some or call the given function
getDefaultValueand return its result. - Example:
declare function tryGenerateNumber(): Maybe<number>;
const result =
tryGenerateNumber()
.map(square) // >= 0
.flatMap(tryInvert)
.valueOrGet(() => -1);Functions
The Maybe package provides additional features that simplify dealing with several Maybe objects.
traverse function
Signature: function traverse<T, U>(items: T[], tryMap: (item: T, index: number) => Maybe<U>): Maybe<U[]>
Utility of such function:
items.map(tryMap)returnsArray<Maybe<U>>which is not practical β- Aim: having the nesting done the other way around:
Maybe<Array<U>>- With Either all values that the partial operation
tryMapmanaged to produce - Or none when no values have been produced
- With Either all values that the partial operation
Example: Search feature in a file Explorer application (like Windows Explorer)
- Display either the found files only, with a highlighting of the matched part in the file name
- Or a message similar to "No files found"
Pseudo-Angular component:
declare function highlight(element: Element, search: string): Maybe<Element>;
@Component() class GridComponent {
@Input() allElements: Element[] = [];
elements: Element[] = [];
noResults = false;
find(search: string): void {
const result = traverse(this.allElements, element => highlight(element, search));
// β Type: `Maybe<Element[]>`
result.match({
some: xs => { this.elements = xs; this.noResults = false; },
none: () => { this.elements = []; this.noResults = true; },
});
}
}apply function
- Signature:
function apply<T, U>(fun: Maybe<(value: T) => U>, arg: Maybe<T>): Maybe<U> - Purpose: as its name implies, the
applyfunction is related to calling a functionfn, in case both function and its arguments came from partial operations. We will be able to callfnonly if everything is present.
Theory (feasible with a true functional language)
In a functional language like Fβ―, functions are automatically curried. For instance, a function with 2 arguments, (a: A, b: B) => C, becomes (a: A) => (b: B) => C once curried, i.e. a function with one argument (a: A) returning another function with one argument (b: B) returning the final value of type C. The advantage is that both functions have the same generic signature T => U: T = A, U = (B => C) for the first one, T = B, U = C for the second one.
So, we can "apply" arguments one at a time with the apply function. But from theory to practice (in TypeScript), there's some pitfalls! Let's look at a example:
type OrderItem = { sku: string; discount: string; };
declare function tryGetPrice(sku: string): Maybe<number>;
declare function tryGetDiscount(discount: string): Maybe<Discount>;
declare function applyDiscount(price: number): (discount: Discount) => number; // βοΈ Curried
const computeOrderItemPrice = (orderItem: OrderItem): Maybe<number> =>
// TODOWith the help of the pipe operator |>, the syntax would be readable:
const computeOrderItemPrice = (orderItem: OrderItem): Maybe<number> =>
Maybe.some(applyDiscount)
|> apply(tryGetPrice(orderItem.sku))
|> apply(tryGetDiscount(orderItem.discount));But we don't have the pipe operator yet. Without it, it's more cumbersome in either cases:
// V1: nested calls are hard to code due to parenthesis
const computeOrderItemPrice = (orderItem: OrderItem): Maybe<number> =>
apply(apply(Maybe.some(applyDiscount),
tryGetPrice(orderItem.sku)),
tryGetDiscount(orderItem.discount));
// V2: Temporary variables help reading in order but bloat code too
const computeOrderItemPrice = (orderItem: OrderItem): Maybe<number> =>
const fn2 = Maybe.some(applyDiscount); // Type: Maybe<(price: number) => (discount: Discount) => number>
const fn1 = apply(fn2, tryGetPrice(orderItem.sku)); // Type: Maybe<(discount: Discount) => number>>
return apply(fn1, tryGetDiscount(orderItem.discount));
};Why not proposing a method on the Maybe object? With such a method, we will be closed to the syntax using the pipe operator:
const computeOrderItemPrice = (orderItem: OrderItem): Maybe<number> =>
Maybe.some(applyDiscount)
.apply(tryGetPrice(orderItem.sku))
.apply(tryGetDiscount(orderItem.discount));Coding such a method is a challenge cause we have the interface Maybe<T> but the apply method must be proposed only with the type Maybe<(arg: T) => U> βοΈ
In practice
π To sum up the issues of the theoretical apply function:
- It cannot be coded as a method easily.
- As a function, it's not practical.
- It works well only with a curried function which is not idiomatic in TypeScript.
- It's possible to curry a function in JavaScript, for instance using Ramda, but its type is another challenge to code in TypeScript.
In practice in TypeScript, it's much simpler to use the mapN function.
mapN function
(a.k.a
liftN)
Let's explain the utility of mapN by comparisons with other methods and functions of the Maybe type:
mapNvsmap:mapNvsapply:applyworks well with curried functions, in order to apply arguments one by one. Also the functions are optional i.e. wrapped in aMaybeobject.mapNworks with regular N-ary functions (i.e. not curried), with the idea to do only one call, specifying all N potential values to pass as arguments to the N-ary function. This way is often more practical in TypeScript than using a curried function and several call to theapplyfunction.
Signature:
N = 1argumentfunction mapN<A, B>(fn: (a: A) => B, maybeA: Maybe<A>): Maybe<B>- π‘ Better call
maybeA.map(fn)directly!
N = 2argumentsfunction mapN<A, B, C>(fn: (a: A, b: B) => C, maybeA: Maybe<A>, maybeB: Maybe<B>): Maybe<C>
- Etc.
N > 4arguments- β οΈ The function accepts more than 4 arguments. Nevertheless, too much arguments is not recommended - @see long parameter list code smell! Consider refactoring the code.
Example:
type OrderItem = { sku: string; discount: string; };
declare function tryGetPrice(sku: string): Maybe<number>;
declare function tryGetDiscount(discount: string): Maybe<Discount>;
declare function applyDiscount(price: number, discount: Discount) => number;
const computeOrderItemPrice = (orderItem: OrderItem): Maybe<number> =>
mapN(applyDiscount,
tryGetPrice(orderItem.sku),
tryGetDiscount(orderItem.discount));βοΈ Note: apply and mapN are of more interest with another type, Result<Success, Error[]>, which is conceptually similar to the union type { value: Success } | { errors: Error[] }. With this type, the Failure case can have multiples values (here called errors) as opposed to none for the Maybe type. apply and mapN applied to Result will collect the errors which is called the "applicative style", as opposed to the "monadic style" of flatMap which is keeping only the first error.
Code comparison: null vs Maybe
1. Single optional result
// V1 : with `null`
const result = find(...); // Result | nil
return result
? handle(result)
: handleNoResults();
// V2 : with `Maybe`
const result = tryFind(...); // Maybe<Result>
return result.match({
some: handleResult,
none: handleNoResults,
});π Maybe is a bit "heavy" but a bit safer, forcing to deal with the absence of value (none case)
β null is acceptable here π
2. Chaining optional results
// V1 : with `null`
const a = getA(...); // A | nil
const b = a ? getB(a) : null; // B | nil
return b ? getC(b) : null; // C | nil
// V2 : with `Maybe`
return tryGetA(...)
.flatMap(tryGetB)
.flatMap(tryGetC);π Cyclomatic complexity goes down from 3 to 1, leading to code more understandable β Maybe wins!
3. Computation decomposed in transitional steps
// V1 : with `null` + pattern Tester (`> 0`) / Doer (`invert`)
const num = generateNumber();
const result = num != null && num > 0
? invert(square(num))
: null;
// V2 : with `Maybe`
const result = tryGenerateNumber()
.map(square)
.flatMap(tryInvert);π V2 expressed more clearly the computation steps: one line per step, in the natural order (square then invert β invert(square(num))). invert step is handled entirely in tryInvert (condition + operation). β Maybe winner π
FAQ
How to create a Maybe instance β
Use either:
Maybe.some(value)to wrap a valueMaybe.none()(orMaybe.none<T>()if necessary, specifying the properT) to indicate the absence of valueMaybe.ofNullable(nullableValue)to convert a nullable value into aMaybeinstance, eithersome(value)if the value is notnullorundefined, elsenone().
βοΈ Notes:
- It's possible to wrap the value
nullin aMaybeinstance (e.g.Maybe.some(null)is possible) but it's not recommended! PreferMaybe.ofNullable()to wrap a nullable value. - It's possible to wrap a function too. @see
applyfunction
How to exit from a Maybe instance β
Use one of the following methods: match or valueOrDefault or valueOrGet. match converges to another type which can be void. In either cases, it is here where the possible absence of value is handled.
π Tips: Delay this "exit" as much as possible, until having the whole partial operation recombined. Otherwise, you probably will have to handle the 2 cases (presence or absence of value) by hand, instead of delegating it to the Maybe instance.
Is it a niche, a (software) crafter stuff, not for every developer β
- ClichΓ©! It's in Java since 2014 as
Optionalβ it's MainStream! - You can use it right now in a TypeScript codebase, front or back.
Do we need to replace null with Maybe everywhere β
Everywhereβ ! TypeScript ecosystem is null friendly (a lot of functions returningnullorundefined). We cannot change it, but ensafe some part of our codebase, the one that is the more valuable or complex.- It's a compromise to found between quality and pragmatism
nullcan still be used in the simpler cases, with thestrictNullChecksafety.Maybeis preferable in the all other cases.
What about unit testing β
Not much more complicated than with a nullable value, since Maybe instances are "equatable":
expect(result) | With null | With Maybe |
|---|---|---|
| No value | toEqual(null) | toEqual(Maybe.none()) |
| Value | toBe(3) | toEqual(Maybe.some(3)) |
What about its implementation β
The implementation of the Maybe type is based on ad-hoc polymorphism, which is the better thing to do in TypeScript: the code is not bloated with if (hasValue) use(value) else useNone()!
The 2 cases, Some and None are coded in separate objects. They are constructed using classes, not using object literals, so that the instances can be equatable i.e. usable with asserter like Jasmine/Jest expect(result).toEqual(Maybe.some(value)). It's due to the fact that, since the methods are in the prototype, they are not used for comparison, contrary to object literals holding their methods as own members.
Where can we find more information β
π More information on map, bind, apply, traverse functions, F# for fun and profit, Scott Wlaschin.
π π«π· Pour ceux qui comprennent le franΓ§ais, j'ai donnΓ© une confΓ©rence sur le type Maybe en TypeScript et en Cβ― : https://youtu.be/Gtu-AGIbRSI.