Helper Functions#

Here is a collection of helper functions used across the examples. You can always download and read the full source code from Gitlab.

log#

import { jest } from '@jest/globals'
import { log } from './log';

//
// Beside spying, we also `mockImplementation()` to prevent the logging
// to actually happen and pollute our unit testing output.
//

describe('when not args are provided', () => {
  beforeEach(() => {
    jest.spyOn(global.console, 'log').mockImplementation();
  });

  afterEach(() => {
    jest.restoreAllMocks();
  });

  it('should only log the divider with the empty line', () => {
    log();

    const calls = console.log.mock.calls;

    expect(calls[0][0]).toEqual('-'.repeat(32));
    expect(calls[1][0]).toBe(undefined);

    jest.restoreAllMocks();
  });

  it('should log devidier, params, and empty line', () => {
    log('hello', 'world', 2 + 3);

    const calls = console.log.mock.calls;

    expect(calls[0][0]).toEqual('-'.repeat(32));
    expect(calls[1][0]).toEqual('hello');
    expect(calls[2][0]).toEqual('world');
    expect(calls[3][0]).toEqual(5);
    expect(calls[4][0]).toEqual(undefined);
  });
});
/**
 * Log the arguments to the console.
 *
 * I mostly use this when I am playing with some idea and I run the npm
 * scripts (see package.json) like this:
 *
 *   $ npm run file vidN/foo.js
 *
 * or
 *
 *   $ npm run watch vidN/foo.js
 *
 * Especially with watch, I then have delimiting “----”-like line to
 * help visualize where each log starts.
 *
 * @param {...any} args Zero or more values to log.
 * @return {Array<args>}.
 *
 * @example
 * log('hey');
 * // → --------------------------------
 * // → hey
 *
 * @example
 * log('h4ck3r, [1, 2]);
 * // → --------------------------------
 * // → h4ck3r
 * // → [ 1, 2 ]
 */
function log(...args) {
  console.log('-'.repeat(32));

  args.forEach(function logArg(arg) {
    console.log(arg);
  });

  // Print an empty line.
  console.log();

  return args;
}

export { log };

id (identity)#

import { id } from './id';

describe('returns its input', () => {
  [
    [1, '1'],
    [null, 'null'],
    [undefined, 'undefined'],
    ['', "'1'"],
    [{ foo: 'bar' }, "{ foo: 'bar' }"],
    [[10, 20], '[10, 20]'],
  ].forEach(([value, description]) => {
    it(`should output its input ${description}`, () => {
      expect(id(value)).toEqual(value);
    });
  });
});
/**
 * The identity function.
 *
 * Simply returns the input untouched.
 *
 * @param {any} x
 * @return {any} x
 *
 * @example
 * id(1);
 * // → 1
 *
 * id({ jedi: 'Yoda' });
 * // → { jedi: 'Yoda' }
 */
function id(x) {
  return x;
}

export { id }

isNil#

import { isNil } from './isNil';

describe('isNil()', () => {
  //
  // We say “empty array/object” with a loose interpretation of “empty",
  // because we know they inherit some methods and properties. We mean
  // “empty” in the sense that we didn't explicitly add values to them
  // ourselves.
  //
  [
    [undefined, 'undefined', true],
    [null, 'null', true],
    ['', 'empty string', false],
    [0, '0 (zero)', false],
    [[], '[] (empty array)', false],
    [{}, '{} (empty object)', false],
    [NaN, 'NaN', false],
  ].forEach(function checkIsNil([input, description, expected]) {
    it(`should return ${expected} for ${description}`, () => {
      expect(isNil(input)).toEqual(expected);
    });
  });
});
/**
 * Checks whether `value` is `undefined` or `null`.
 *
 * Returns `true` if, and only if, `value` is `undefined` or `null`;
 * return `false` for any other value, including `false` empty string or
 * array, etc.
 *
 * @sig * -> Boolean
 *
 * @param {any} value
 * @return {boolean}
 *
 * @example
 * isNil(undefined);
 * // → true
 *
 * isNil(null);
 * // → true
 *
 * isNil(false);
 * // → false
 *
 * isNil(0);
 * // → false
 *
 * isNil('');
 * // → false
 *
 * isNil(NaN);
 * // → false
 *
 * isNil([]);
 * // → false
 */
function isNil(value) {
  return value === undefined || value === null;
}

export { isNil }

fromNullable#

import { fromNullable } from './fromNullable';
import {
  Left,
  Right,
} from './Either';

//
// Not sure about the best way to assert that we got a Left or Right,
// but it seems the toString approach is good enough for our purposes.
//

[
  [undefined, 'undefined'],
  [null, 'null'],
].forEach(([input, description]) => {
  describe(`when input is ${description}`, () => {
    it('should return a Left', () => {
      expect(
        String(fromNullable(input))
      ).toEqual(String(Left(input)));
    });
  });
});

//
// We say “empty array/object” with a loose interpretation of “empty",
// because we know they inherit some methods and properties. We mean
// “empty” in the sense that we didn't explicitly add values to them
// ourselves.
//
[
  ['', 'empty string'],
  [0, '0 (zero)'],
  [[], '[] (empty array)'],
  [{}, '{} (empty object)'],
  [NaN, 'NaN'],
].forEach(([input, description]) => {
  describe(`when input is ${description}`, () => {
    it('should return a Right', () => {
      expect(
        String(fromNullable(input))
      ).toEqual(String(Right(input)));
    });
  });
});
import { isNil } from './isNil.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);
}

export { fromNullable }

Either (Left, Right)#

Either Unit Tests#

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().chain()', () => {
    [
      ['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).chain(fn))
        ).toEqual(String(Left(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().chain()', () => {
    [
      ['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(
          Right(input).chain(fn)
        ).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');
    });
  });
});

Either Implementation#

/// <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 {
    chain: _ => Left(value),
    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 {
    chain: f => f(value),
    map: f => Right(f(value)),
    fold: (_, rightFn) => rightFn(value),
    toString: () => `Right(${value})`,
  };
}

export {
  Right,
  Left,
}

tryCatch#

import { Left, Right } from './Either.js';
import { tryCatch } from './tryCatch.js';

describe('when we get an exception', () => {
  it('should return a Left container', () => {
    expect(
      String(tryCatch(() => undefined.split('-')))
    ).toEqual(
      // This hard-coded TypeError string bothers me. What other
      // approach could be used?
      String(
        Left(
          "TypeError: Cannot read property 'split' of undefined"
        )
      )
    );
  });
});

describe('when we get a successfull result', () => {
  it('should return a Right container', () => {
    expect(
      String(tryCatch(() => 'hello-world'.split('-')))
    ).toEqual(
      String(Right('hello,world'))
    );
  });
});
import { Left, Right } from './Either.js';

/**
 * Turns a try/catch into an `Either`, composable container.
 *
 * @param {Function} f
 * @return {Either}
 */
const tryCatch = f => {
  try {
    return Right(f());
  } catch (err) {
    return Left(err);
  }
};

export { tryCatch }