03 - Enforce a null check with composable code branching using Either¶
Here’s the video lesson.
Intro to Either, Left and Right¶
Either
is a type that provides two sub types:
Right
: for success conditionsLeft
: for failure conditions.
The difference between Left
and Right
when compared with
Box
is in the way we define fold()
. Left().map()
is also
different than Box().map()
, as well see in the next few
paragraphs.
We generally don’t know beforehand if we have a success or failure
case, and therefore, our fold must account for both. Instead of
receiving one function, like Box(v).fold(f)
, both
Left(v).fold(?, ?)
and Right(v).fold(?, ?)
receive an error
handling function and a success handling function. Something like
this:
.fold(errorFn, successFn)
Left().map()
is peculiar because it refuses to apply its function
argument to the value. Since we are dealing with some sort of failure,
we can’t map over the value. We don’t have a “value”, but some sort
of error instead.
Note
It is also common to say left and right functions to refer to the error and success functions:
.fold(leftFn, rightFn)
Finally, note that for Left(value).fold(leftFn, rightFn)
we don’t
use it’s second function argument rightFn
, and for
Right(value).fold(leftFn, rightFn)
we don’t use its leftFn
. To
avoid problems with linters or TSServer, we can ignore non-used
parameters using the underscore.
Tip
Several programming languages use the underscore _
U+5f LOW
LINE to indicate that the parameter in that position should be
ignored.
Using this Either
type we can do pure functional error handling,
code branching, null checks and other things that capture the concept
of disjunction, that is, the concept of “or”.
OK, this is a high level overview of the subject. See the JSDoc Type Definitions below.
Left and Right Unit Tests¶
Pay special attention how the unit tests assert that Left().map()
DOES NOT apply the provided function to the value, and the value
remains unmodified.
Also, notice that we assert that fold()
applies the left
function for Left(v).fold(l, r)
and the right function for
Right(v).fold(l, r)
.
import { Right, Left } from './Either';
describe('Left()', () => {
describe('Left().toString()', () => {
[
[1, 'Left(1)'],
['FP', 'Left(FP)'],
].forEach(([input, output]) => {
it(`should toString ${input}`, () => {
expect(String(Left(input))).toEqual(output);
});
});
});
describe('Left().map()', () => {
[
['id', 'Tomb Raider I 1996', v => v, 'Tomb Raider I 1996'],
['toLower', 'JEDI', s => s.toLowerCase(), 'JEDI'],
['sub1', 0, i => i - 1, 0],
].forEach(([name, input, fn, output]) => {
it(`should NOT apply ${name}`, () => {
expect(
String(Left(input).map(fn))
).toEqual(String(Left(output)));
});
});
});
describe('Left().fold()', () => {
it('should return the unprocessed value', () => {
expect(
Left('jedi')
.fold(
_ => 'Error: no processing performed',
str => str.toUpperCase()
)
).toEqual('Error: no processing performed');
});
});
describe('Left().map().fold()', () => {
it('should return the unprocessed value', () => {
expect(
Left('jedi')
.map(str => str.toUpperCase())
.map(str => str.split(''))
.map(arr => arr.join('-'))
.fold(
_ => 'Error: no processing performed',
str => str.toUpperCase()
)
).toEqual('Error: no processing performed');
});
});
});
describe('Right()', () => {
describe('Right().toString()', () => {
[
[1, 'Right(1)'],
['FP', 'Right(FP)'],
].forEach(([input, output]) => {
it(`should toString ${input}`, () => {
expect(String(Right(input))).toEqual(output);
});
});
});
describe('Right().map()', () => {
[
['id', 'Tomb Raider I 1996', v => v, 'Tomb Raider I 1996'],
['toLower', 'JEDI', s => s.toLowerCase(), 'jedi'],
['sub1', 0, i => i - 1, 0 - 1],
].forEach(([name, input, fn, output]) => {
it(`should apply ${name} correctly`, () => {
expect(
String(Right(input).map(fn))
).toEqual(String(Right(output)));
expect(
Right(input).map(fn).toString()
).toEqual(Right(output).toString());
});
});
});
describe('Right().fold()', () => {
[
['id', 1, v => v, 1],
['add1', 0, v => v + 1, 0 + 1],
['split and join', 'xyz', v => v.split('').join('-'), 'x-y-z'],
['Number', '3.14', Number, 3.14],
].forEach(([name, input, fn, output]) => {
it(`should apply ${name} to ${input} and produce ${output}`, () => {
expect(Right(input).fold(_ => 'Error', fn)).toEqual(output);
});
});
});
describe('Right().map().fold()', () => {
it('should return the processed value', () => {
expect(
Right('jedi')
.map(str => str.toUpperCase())
.map(str => str.split(''))
.fold(_ => 'Error', arr => arr.join('-'))
).toEqual('J-E-D-I');
});
});
describe('Right().map().fold()', () => {
it('should return the processed value', () => {
expect(
Right('jedi')
.map(str => str.toUpperCase())
.map(str => str.split(''))
.map(arr => arr.join('-'))
.fold(_ => 'Error', v => v)
).toEqual('J-E-D-I');
});
it('should toLower, split, join and then unbox', () => {
const toLower = s => s.toLowerCase();
const split = (sep, val) => val.split(sep);
const join = (sep, val) => val.join(sep);
const strToArr = split.bind(null, '');
const joinWithUnderscore = join.bind(null, '_');
const f = () => undefined;
expect(
Right('JEDI').map(toLower).map(strToArr).fold(f, joinWithUnderscore)
).toEqual('j_e_d_i');
expect(
Right('YODA')
.map(s => s.toLowerCase())
.map(s => s.split(''))
.fold(_ => undefined, s => s.join('-'))
).toEqual('y-o-d-a');
});
});
});
JSDoc Type Definitions¶
It is nice to have well defined type definitions for these to help us read and write code, including the awesome TSServer intellisense:
No, this is not TypeScript. It is vanilla JavaScript! But the magnificent TSServer is brilliant enough to provide types from the JSDoc comments.
/**
* @typedef {any} Value
*
* A value consumed and/or produced by a container's `map` and/or `fold`
* functions.
*/
/**
* @typedef {function(function(Value): Value): LeftContainer} LeftMapFn
*
* @sig (Value -> Value) -> LeftContainer
*
* `LeftMapFn` is a function that takes a callback function and returns
* a `LeftContainer`. The callback takes a `Value` and returs a `Value`.
*/
/**
* @typedef {function(function(Value): Value): RightContainer} RightMapFn
*
* @sig (Value -> Value) -> RightContainer
*
* `RightMapFn` is a function that takes a callback function and returns
* a `RightContainer`. The callback takes a `Value` and returs a `Value`.
* The
*/
/**
* @typedef {function(function(Value): Value): Value} LeftFoldFn
*
* @sig (Value -> Value) -> Value
*
* `LeftFoldFn` is a function that takes a callback function and returns
* a `Value`. The callback takes a `Value` and returs a `Value`.
*/
/**
* @typedef {function(function(Value): Value): Value} RightFoldFn
*
* @sig (Value -> Value) -> Value
*
* `RightFoldFn` is a function that takes a callback function and returns
* a `Value`. The callback takes a `Value` and returs a `Value`.
*/
/**
* @typedef {Object} LeftContainer
* @property {LeftMapFn} map This DOES NOT apply the function to the value.
* `map()` on a left container refuses to apply the function we do not have a
* value, but some sort of error instead.
* @property {LeftFoldFn} fold Applies the left function over the value
* and ignores the right function.
* @property {function(): string} toString
*/
/**
* @typedef {Object} RightContainer
* @property {RightMapFn} map Maps a function over the value.
* @property {RightFoldFn} fold Applies the right function over the
* value and ignores the left function.
* @property {function(): string} toString
*/
/**
* This is a type like `Maybe` in Haskell. It indicates that we
* either have a result (`Right`) or a failure of some sort (`Left`).
*
* @typedef {LeftContainer|RightContainer} Either
*/
Implementation of Left and Right¶
/// <reference path="./typedefs.js" />
/**
* Creates a chainable, `LeftContainer` container.
*
* This function allows the chaining `map()` invocations in a composable
* way, and, if desired, unbox the value using `fold()`.
*
* However, `Left` is supposed to handle failures, and therefore,
* `Left().map()` DOES NOT actually apply the function to the value.
* It simply returns another `LeftContainer` without modifying the
* value.
*
* @sig Value -> LeftContainer
*
* @param {Value} value
* @return {LeftContainer}
*
* @example
* Left('YODA')
* .map(s => s.toLowerCase())
* .map(s => s.split(''))
* .fold(_ => 'error', s => s.join('-'))
* // → 'error'
*/
function Left(value) {
return {
map: _ => Left(value),
fold: (leftFn, _) => leftFn(value),
toString: () => `Left(${value})`,
};
}
/**
* Creates a chainable `RightContainer` container.
*
* This function allows the chaining of `map()` invocations in a composable
* way, and, if desired, unbox the value using `fold()`.
*
* @sig Value -> RightContainer
*
* @param {Value} value
* @return {RightContainer}
*
* @example
* RightContainer('YODA')
* .map(s => s.toLowerCase())
* .map(s => s.split(''))
* .fold(_ => 'error', s => s.join('-'))
* // → 'y-o-d-a'
*/
function Right(value) {
return {
map: f => Right(f(value)),
fold: (_, rightFn) => rightFn(value),
toString: () => `Right(${value})`,
};
}
export {
Right,
Left,
}
Use of Left and Right¶
The Problem¶
First of all, the example that attempts to blindly chain method
invocation on values (not using Either
Left
and Right
sub
types):
import { log } from '../lib/lib.js';
/**
* Attempts to get a color value by name.
*
* @param {string} name The color name, like ‘red’, ‘green’, or ‘blue’.
* @return {undefined|string}
*/
function findColor(name) {
return {
red: '#ff4444',
green: '#0fa00f',
blue: '#3b5998'
}[name];
}
//
// OK
//
const blue = findColor('blue').slice(1);
//
// NOK
//
// Blows up because can't slice on undefined.
//
const yellow = findColor('yellow').slice(1);
// TypeError: Cannot read property 'slice' of undefined
// 😲
log(blue, yellow);
// Doesn't even log because an exception happens before
// we reach this log.
The problem is that certain functions (or methods if you prefer) can
only be applied to certain values. findColor()
returns
undefined
when it can’t find the color by the name provided. But
we are blindly trying to chain .slice()
on the resulting value and
we have no guarantees that it is a value whose prototype provides a
slice()
method.
$ node --interactive
> ''.slice(5)
''
> 'ECMAScript'.slice(4)
'Script'
> [].slice(7)
[]
> [1, 2, 3].slice(1)
[ 2, 3 ]
> ['.js', '.ts', '.rb', '.hs', '.c'].slice(3)
[ '.hs', '.c' ]
> (42).slice(1)
Uncaught TypeError: 42.slice is not a function
> /regex/.slice(5)
Uncaught TypeError: /regex/.slice is not a function
> null.slice(1)
Uncaught TypeError: Cannot read property 'slice' of null
> undefined.slice(3)
Uncaught TypeError: Cannot read property 'slice' of undefined
In our particular case, we can slice on strings and arrays (even empty ones) without raising an exception, but not on values of some other types.
Note
This is not an exhaustive list, but just a quick demonstration of the problem.
Actually Making Use of Left and Right¶
/// <reference path="./typedefs.js" />
import { log } from '../lib/lib.js';
import { Left, Right } from './Either.js';
/**
* Attempts to get a color value by name.
*
* @param {string} name The color name, like ‘red’, ‘green’, or ‘blue’.
* @return {Either}
*/
function findColor(name) {
const color = {
red: '#ff4444',
green: '#0fa00f',
blue: '#3b5998'
}[name];
return color ? Right(color) : Left(undefined);
}
const red = findColor('red')
.map(color => color.slice(1))
.fold(_ => 'No color', hexStr => hexStr.toUpperCase());
const yellow = findColor('yellow')
.map(color => color.slice(1))
.fold(_ => 'No color', hexStr => hexStr.toUpperCase());
log(red, yellow);
// → FF4444
// → No color
Notice how we changed the implementation of findColor()
to return
an Either
type (which means it will return either a
LeftContainer
or a RightContainer
).
And because we changed findColor()
to use Either
, we now
cannot be blindsided by just chaining slice()
and hoping for the
best. No, we now map over the value to apply slice()
, and the
code will branch out correctly depending on whether we have a left
for a right container and things cannot possibly blow up.
We decided to produce the string ‘No color’ when a color cannot be
returned. We could have taken other approaches, like returning a
default color, an empty string, undefined, etc. We would make that
choice based on what client code would be expected to handle the
result of findColor()
.
Improving With fromNullable¶
Now our findColor()
returns an Either
, which is a more
functional style approach and considerably reduces the chance of
exceptions. Sadly, though, it now contains an assignment, which is
what we tried to avoid in our first examples from video 1 on Box.
We then implement fromNullable()
, which takes a value wraps it
into an Either
, correctly using Left
or Right
appropriately.
/// <reference path="./typedefs.js" />
import {
log,
isNil,
} from '../lib/index.js';
import { Left, Right } from './Either.js';
/**
* Wraps the value into an `Either` type.
*
* @param {any} value
* @return {Either}
*/
function fromNullable(value) {
return isNil(value) ? Left(value) : Right(value);
}
/**
* Attempts to get a color value by name.
*
* @param {string} name The color name, like ‘red’ or ‘blue’.
* @return {Either}
*/
function findColor(name) {
return fromNullable({
red: '#ff4444',
green: '#0fa00f',
blue: '#3b5998'
}[name]);
}
const yellow = findColor('yellow')
.map(color => color.slice(1))
.fold(_ => 'No color', hexStr => hexStr.toUpperCase());
const blue = findColor('red')
.map(color => color.slice(1))
.fold(_ => 'No color', hexStr => hexStr.toUpperCase());
log(
yellow,
blue,
);
// → No color
// → FF4444
Final Thoughts¶
It is important to keep in mind that we are now branching out based on
whether we have a value or not. Not having a value means undefined
or null
as per our current implementation. Our fromNullable()
function branches to Left()
in those two cases. Beware: we are not
totally and magically free from problems. We just know that we have a
value or not, but we don’t know the type of that value.
function getId(user) {
return fromNullable(user.id);
}
log(
getId({ id: 103 })
.map(i => i.split(''))
.fold(_ => 'Oops', i => i),
);
The code above results in an exception:
TypeError: i.split is not a function
Our map()
is the implementation from Right()
, which does apply
its callback function to the value. But the value here is a number,
and Number.prototype
does not have a split()
method. We
still have the responsibility of applying proper functions depending
on the type of values we are dealing with. So, for example, perhaps
in the example above, we could try to make sure we have a string by
first mapping String
and then doing the splitting:
log(
getId({ id: 103 })
.map(String)
.fold(_ => 'Oops', i => i.split('')),
);
// → [ '1', '0', '3' ]