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 conditions

  • Left: 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:

Vim TSServer JSDoc TypeDef 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.

Ivan Drago - Rocky IV - “I cannot be defeated.”

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' ]