Object-oriented Maybe type in TypeScript
npm install ts-maybe-typeMaybe 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.
null free) and more structured (separation of data flow and control flow).
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)_.
null is still not ideal
Maybe
Maybe type as return type
map method
flatMap method
filter method
fillWhenNone method
match method - Pseudo pattern matching objet-oriented style
valueOrDefault method
valueOrGet method
traverse function
apply function
mapN function
null vs Maybe
Maybe instance β
Maybe instance β
null with Maybe everywhere β
null substitute!
array and its methods filter, map, flatMap
null, undefined billion dollars mistake)
Maybe is a generic type to model the absence of value.
strictNullCheck option to keep us safe with null. Why not using null in 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.
null is in itself problematic as it can lead to:
if (o != null) { useNonNull(o); }
o ? o.key : null
find(...) || defaultValue or with ?? (Cβ―, Ts 3.7)
o?.k (Cβ―, Ts 3.7)
if/else blocks chained or, worst, nested (arrow anti-pattern)
Maybe is a "box" that can contain:
T,
Maybe type models the absence of value.
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.
1/0 causes no errors returning a edge case value Infinity, but not indicated in the signature (still number).
Maybe to indicate that the invert computation is a partial operation.
n !== 0, instead of returning 1/n, we wrap this value in the Maybe instance, using the factory function Maybe.some(1/n)
n === 0, instead of returning null or undefined or NaN, we return an empty Maybe instance, using the factory function Maybe.none()
ts
// 1. Explicit return type
const invert = (n: number): Maybe =>
// -------------------- βοΈ
n ? Maybe.some(1 / n)
: Maybe.none();
// 2. Inferred return type
const invert = (n: number) =>
n ? Maybe.some(1 / n)
: Maybe.none();
// ------ βοΈ
`
$3
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
- Filtering: @see filter
- Mapping: @see map, flatMap
- Match exhaustively both cases to converge to a final "value" or do a final "IO" operation
- @see match
- Unwrap the optional value if we give a default value when there's none
- @see valueOrDefault, valueOrGet
$3
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
- traverse function for "mass processing"
βοΈ They respects functional programming principles that make code much safer because deterministic:
- Immutability : Maybe instance 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.
$3
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 valueOrDefault or valueOrGet to get the final value, unwrapped or defaulted
- For instance a string with the formatted value or an error message
- With match() for a final operation producing a value (that can be of another type) or not (see Angular example of traverse function)
Methods
$3
> (a.k.a lift, Select LINQ)
- Aim: executing a mapping operation which is total (= not partial)
- Expressed as a function with signature (value: T) -> U
- Schema: Maybe β map(operation) β Maybe
- Case count: 2 β tracks some and none not connected:
`txt
Input Operation Output
1. some(x) ββββΊ map( x -> y ) ββββΊ some(y)
2. none()Β ββββΊ map( .... ) ββββΊ none()
`
Example:
`ts
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)
`
$3
> (a.k.a andThen, bind, SelectMany LINQ)
- Aim: executing a mapping operation which is partial
- Expressed as a function with signature (value: T) -> Maybe
- Why not use map?
- Because we will have nested box Maybe which is not practical.
- Solution: flatten the result
- Case count: 3 β "tracks" some and none are connected:
`txt
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
`ts
type Order = { price: number };
declare function getOrders: (clientId: number) => Maybe;
// Return none when client is unknown
declare function sum: (numbers: number[]) => number;
`
`ts
const computeAveragePrice = (orders: Order[]): Maybe =>
orders.length
? Maybe.some(sum(orders.map(x => x.price)) / orders.length)
: Maybe.none();
const computeAverageOrderPrice = (clientId: number) =>
getOrders(clientId)
.flatMap(computeAveragePrice);
`
$3
> (a.k.a Where LINQ)
- map, flatMap β mapping of valeur
- filter β skip a value when it does not satisfy a condition, evaluated by the given predicate
- Signature 1: filter(predicate: (value: T) => boolean): Maybe
- Signature 2: filter
- Cases: 3 - tracks some and none connected - same "gutter effect" as flatMap:
`txt
Input Predicate Output
1a. some(x) ββ¬ββΊ filter( x -> true ) ββββΊ some(y)
1b. βββΊ filter( x -> false ) ββ
2. none()Β ββββΊ filter( .... ) ββ΄ββΊ none()
`
$3
- Signature: fillWhenNone(defaultValue: T): Maybe
- Description: fillWhenNone method has an opposite purpose compared to filter: populating some value when it is missing.
`txt
Input Value Output
1a. some(x) ββ¬ββΊ fillWhenNone(β¦) ββ¬ββΊ some(x)
2. none()Β ββββΊ fillWhenNone(x) ββ
`
Example:
`ts
// Simulate "OR" operator between 2 optional numbers
function combineResults(results1: Maybe, results2: Maybe): Maybe {
return results1.match({
some: x => results2.map(y => x + y).fillWhenNone(x),
none: () => results2,
});
}
combineResults(Maybe.none(), Maybe.none()); // β Maybe.none()
combineResults(Maybe.some(1), Maybe.none()); // β Maybe.some(1)
combineResults(Maybe.none(), 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 valueOrDefault because the former keeps the value in a box while the later unwraps it.
$3
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(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 be void).
Example #1:
`ts
const threeOrUndefined = [1, 2, 3, 4].find(x => x === 3);
// β Type: number | undefined
const maybeThree = Maybe.ofNullable(threeOrUndefined);
// β Type: Maybe
// 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:
`ts
const average = (total: number, count: number): Maybe =>
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).
$3
> (a.k.a defaultIfNone, orElse Java Optional, FirstOrDefault LINQ)
- Signature: valueOrDefault(defaultValue: T): T
- Description: unwrap the value if there is some or return the given defaultValue.
- Example:
`ts
declare function tryGenerateNumber(): Maybe;
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 Maybe type: Maybe.none β -1
$3
> (a.k.a orElseGet Java Optional)
- Signature: valueOrGet(getDefaultValue: () => T): T
- Description: unwrap the value if there is some or call the given function getDefaultValue and return its result.
- Example:
`ts
declare function tryGenerateNumber(): Maybe;
const result =
tryGenerateNumber()
.map(square) // >= 0
.flatMap(tryInvert)
.valueOrGet(() => -1);
`
Functions
The Maybe package provides additional features that simplify dealing with several Maybe objects.
$3
Signature: function traverse
Utility of such function:
- items.map(tryMap) returns Array which is not practical β
- Aim: having the nesting done the other way around: Maybe
- With Either all values that the partial operation tryMap managed to produce
- Or none when no values have been produced
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:
`ts
declare function highlight(element: Element, search: string): Maybe;
@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
result.match({
some: xs => { this.elements = xs; this.noResults = false; },
none: () => { this.elements = []; this.noResults = true; },
});
}
}
`
$3
- Signature: function apply
- Purpose: as its name implies, the apply function is related to calling a function fn, in case both function and its arguments came from partial operations. We will be able to call fn only 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:
`ts
type OrderItem = { sku: string; discount: string; };
declare function tryGetPrice(sku: string): Maybe;
declare function tryGetDiscount(discount: string): Maybe;
declare function applyDiscount(price: number): (discount: Discount) => number; // βοΈ Curried
const computeOrderItemPrice = (orderItem: OrderItem): Maybe =>
// TODO
`
With the help of the pipe operator |>, the syntax would be readable:
`ts
const computeOrderItemPrice = (orderItem: OrderItem): Maybe =>
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:
`ts
// V1: nested calls are hard to code due to parenthesis
const computeOrderItemPrice = (orderItem: OrderItem): Maybe =>
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 =>
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:
`ts
const computeOrderItemPrice = (orderItem: OrderItem): Maybe =>
Maybe.some(applyDiscount)
.apply(tryGetPrice(orderItem.sku))
.apply(tryGetDiscount(orderItem.discount));
`
Coding such a method is a challenge cause we have the interface Maybe 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:
1. It cannot be coded as a _method_ easily.
- As a _function_, it's not practical.
2. 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.
$3
> (a.k.a liftN)
Let's explain the utility of mapN by comparisons with other methods and functions of the Maybe type:
- mapN vs map:
- map and flatMap methods are dealing with unary functions, i.e. functions taking only one argument.
- apply and mapN functions are is dealing with N-ary functions.
- mapN vs apply:
- apply works well with curried functions, in order to apply arguments one by one. Also the functions are optional i.e. wrapped in a Maybe object.
- mapN works 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 the apply function.
Signature:
- N = 1 argument
- function mapN(fn: (a: A) => B, maybeA: Maybe): Maybe
- π‘ Better call maybeA.map(fn) directly!
- N = 2 arguments
- function mapN(fn: (a: A, b: B) => C, maybeA: Maybe, maybeB: Maybe): Maybe
- Etc.
- N > 4 arguments
- β οΈ 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:
`ts
type OrderItem = { sku: string; discount: string; };
declare function tryGetPrice(sku: string): Maybe;
declare function tryGetDiscount(discount: string): Maybe;
declare function applyDiscount(price: number, discount: Discount) => number;
const computeOrderItemPrice = (orderItem: OrderItem): Maybe =>
mapN(applyDiscount,
tryGetPrice(orderItem.sku),
tryGetDiscount(orderItem.discount));
`
βοΈ Note: apply and mapN are of more interest with another type, Result, 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
$3
`ts
// V1 : with null
const result = find(...); // Result | nil
return result
? handle(result)
: handleNoResults();
// V2 : with Maybe
const result = tryFind(...); // Maybe
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 π
$3
`ts
// 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
`ts
// 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
$3
Use either:
- Maybe.some(value) to wrap a value
- Maybe.none() (or Maybe.none if necessary, specifying the proper T) to indicate the absence of value
- Maybe.ofNullable(nullableValue) to convert a nullable value into a Maybe instance, either some(value) if the value is not null or undefined, else none().
βοΈ Notes:
- It's possible to wrap the value null in a Maybe instance (e.g. Maybe.some(null) is possible) but it's not recommended! Prefer Maybe.ofNullable() to wrap a nullable value.
- It's possible to wrap a function too. @see apply function 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.
$3
- 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.
$3
- ~~Everywhere~~ β ! TypeScript ecosystem is null friendly (a lot of functions returning null or undefined). 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
- null can still be used in the simpler cases, with the strictNullCheck safety.
- Maybe is preferable in the all other cases.
$3
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)) |
$3
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.
$3
π More information on map, bind, apply, traverse functions, F# for fun and profit, Scott Wlaschin.