02 - Refactor Imperative to Composed Expression using Box#

Here’s the video lesson.

Intro#

This video is about refactoring three functions that are originally implemented in a very procedural and imperative style to a more functional, composed expressions using the Box container type.

Box#

Let’s first extract Box into its own module and unit test it.

The Unit Tests#

import { Box } from './box';

describe('Box()', () => {
  [
    [1, 'Box(1)'],
    ['FP', 'Box(FP)'],
  ].forEach(([input, output]) => {
    it(`should toString ${input}`, () => {
      expect(String(Box(input))).toEqual(output);
    });
  });

  //
  // Not sure how to unit test map without any help from the
  // stringification of the object, since we have to assert that
  // whatever function pass given map(), it does apply to the value and
  // return the box with the value modified by that function.
  //
  // We need fold to unbox the value.
  //
  describe('Box.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(Box(input).map(fn))).toEqual(String(Box(output)));
        expect(Box(input).map(fn).toString()).toEqual(Box(output).toString());
      });
    });
  });

  describe('Box.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(Box(input).fold(fn)).toEqual(output);
      });
    });
  });

  describe('should have chainable map with fold unboxing the value', () => {
    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, '_');

      expect(
        Box('JEDI').map(toLower).map(strToArr).fold(joinWithUnderscore)
      ).toEqual('j_e_d_i');

      expect(
        Box('YODA')
          .map(s => s.toLowerCase())
          .map(s => s.split(''))
          .fold(s => s.join('-'))
      ).toEqual('y-o-d-a');
    });
  });
});

Box Implementation#

/**
 * A Value consumed and produced by the Container `map's function.
 *
 * @typedef {any} Value
 */

/**
 * @typedef {Object} Container
 * @property {function(function(Value): Value): Container} map Map a
 *   function over the Value.
 * @property {function(): string} toString Our custom stringification
 *   of the object.
 */

/**
 * Creates a chainable container.
 *
 * This function allows us to chain map() invocations in a composable
 * way, and, if desired, unbox the value using fold().
 *
 * @sig Value -> Container
 *
 * @param {Value} val
 * @return {Container}
 *
 * @example
 * Box('YODA')
 *   .map(s => s.toLowerCase())
 *   .map(s => s.split(''))
 *   .fold(s => s.join('-'))
 * // → 'y-o-d-a'
 */
function Box(value) {
  return {
    map: f => Box(f(value)),
    fold: f => f(value),
    toString: () => `Box(${value})`,
  };
}

export { Box }

Our Box.toString method does do a very good job in stringifying non-literal values, like arrays and objects. We could get fancy and do more complex stuff but we do not care much for that. We are interested in chaining and unboxing the value, and that means map and fold.

Another important note is that the vid01 only used unary functions while exemplifying the chaining. Take a look at the Box unit tests and see that I did some currying and partial application to work with split and join, whose arity is two.

moneyToFloat()#

The Test Suite#

// import { moneyToFloat } from './money-to-float-v1.js';
import { moneyToFloat } from './money-to-float-v2.js';

describe('moneyToFloat()', () => {
  [
    ['$0.00', 0], /* <1> */
    ['$1.00', 1], /* <2> */
    ['$1.10', 1.1], /* <3> */
    ['$1.99', 1.99],
    ['$3.14', 3.14],
  ].forEach(([input, output]) => {
    it(`should parse '${input}' USD to a ${output} float value`, () => {
      expect(moneyToFloat(input)).toEqual(output);
    });
  });
});

Note

(1), (2), (3): When we produce a number in JavaScript, its output is reduced to the simplest possible form. For example 1.00 becomes 1, and 1.10 becomes 1.1. The moneyTofloat function is about parsing the string to a number. It does not format that number so some trailing and leading zeroes are dropped. Careful though, leading zeros cause octal interpretation under certain circumstances.

Number('017')
// → 15 (octal interpretation)

Number('018')
// → 18 (zero is dropped, decimal interpretation because 8
// is not part of octal system and the leading zero means
// nothing in this case)

Number('0.20')
// → 0.2 (leading zero is significant here, trailing zero
// not significant, so it is dropped)

Again, parsing numbers and printing them in number form is not the same as formatting them for UIs.

Imperative Style#

First, the normal, procedural, imperative style implementation:

/**
 * Parses a USD monetory string to its float numeric representation.
 *
 * ASSUME: The input is in the proper USD monetory format, e.g '$2.99'.
 *
 * @param {string} s The USD currency.
 * @return {number} The parsed floatish value.
 *
 * @sig String -> Number
 *
 * @example
 *
 * moneyToFloat('$1.10');
 * // → 1.1
 *
 * moneyToFloat('$4.99');
 * // → 4.99
 */
function moneyToFloat(s) {
  return parseFloat(s.replace(/\$/g, ''));
}

export { moneyToFloat }

Functional Style#

Using our “container”.

import { Box } from '../lib/box';

/**
 * Parses a USD monetory string to its float numeric representation.
 *
 * ASSUME: The input is in the proper USD monetory format, e.g '$2.99'.
 *
 * @param {string} s The USD currency.
 * @return {number} The parsed floatish value.
 *
 * @sig String -> Number
 *
 * @example
 *
 * moneyToFloat('$1.10');
 * // → 1.1
 *
 * moneyToFloat('$4.99');
 * // → 4.99
 */
function moneyToFloat(s) {
  return Box(s)
    .map(s => s.replace('\$', ''))
    .fold(s => parseFloat(s));
}

export { moneyToFloat }

In this case we indeed have more characters of code, but it has unnested the expression and removed all the assignments. It endows some other advantages as mentioned in the previous lesson.

Note we map operations over the value inside the container. The last step usually involves fold so we apply one last function to the value while also unboxing it so we have access to the value itself, and not the full container.

percentToFloat()#

The Unit Tests#

// import { percentToFloat } from './percent-to-float-v1.js';
import { percentToFloat } from './percent-to-float-v2.js';

describe('percentToFloat()', () => {
  [
    ['20%', 0.2],
    ['5%', 0.05],
    ['50%', 0.5],
    ['100%', 1],
  ].forEach(([input, output]) => {
    it(`should parse '${input}' USD to a ${output} float value`, () => {
      expect(percentToFloat(input)).toEqual(output);
    });
  });
});

Imperative Style#

/**
 * Parses a percent string into its numeric value.
 *
 * ASSUME: The input is a valid percent string like '50%'.
 *
 * @param {string} s The percentage
 * @return {number} The parsed floatish value.
 *
 * @sig String -> Number
 *
 * @example
 *
 * percentToFloat('20%');
 * // → 0.2
 *
 * percentToFloat('5%');
 * // → 0.05
 */
function percentToFloat(s) {
  const replaced = s.replace(/\%/g, '');
  const number = Number.parseFloat(replaced);
  return number * 0.01;
}

export { percentToFloat }

Instead of all the temp variables, we could implement with nesting of invocations, like this:

function percentToFloat(percentStr) {
  return parseFloat(s.replace(/\%/g, '')) * 0.01;
}

Functional Style#

import { Box } from '../lib/box';

/**
 * Parses a percent string into its numeric value.
 *
 * ASSUME: The input is a valid percent string like '50%'.
 *
 * @param {string} s The percentage
 * @return {number} The parsed floatish value.
 *
 * @sig String -> Number
 *
 * @example
 *
 * percentToFloat('20%');
 * // → 0.2
 *
 * percentToFloat('5%');
 * // → 0.05
 */
function percentToFloat(percentStr) {
  return Box(percentStr)
    .map(s => s.replace(/\%/g, ''))
    .map(s => Number.parseFloat(s))
    .fold(f => f * 0.01);
}

export { percentToFloat }

We are not required to pass the value directly to Box. We can do some pre-processing beforehand, e.g:

Box(percentStr.replace(/\%/g, '')
  .map(s => Number.parseFloat(s))
  .fold(n => n * 0.01)

We do not always need to use containers, and we are not forced to do all the operations from the container chaining. We can use our best judgement according to each situation. It is important to master the tools and techniques so we can then make informed decisions on how, where and when to use them.

applyDiscount()#

The Unit Tests#

// import { applyDiscount } from './apply-discount-v1';
import { applyDiscount } from './apply-discount-v2';

describe('applyDiscount()', () => {
  [
    ['$100', '20%', 80],
    ['$50', '5%', 47.5],
    ['$3.14', '0%', 3.14],
    ['$3.14', '100%', 0],
  ].forEach(([price, discount, total]) => {
    it(`should dicount ${discount} from ${price}`, () => {
      expect(applyDiscount(price, discount)).toEqual(total);
    });
  });
});

Imperative Style#

import { moneyToFloat } from './money-to-float-v2';
import { percentToFloat } from './percent-to-float-v2';

/**
 * Applies a dicount to the given price.
 *
 * ASSUME: price and discount are proper price and discount strings,
 * e.g: '$49.99' and '20%'.
 *
 * @sig String String -> Number
 *
 * @param {string} price
 * @param {string} discount
 * @return {number}
 *
 * @example
 * applyDiscount('$100', '20%');
 * // → 80
 *
 * @example
 * applyDiscount('$50.00', '5%');
 * // → 47.5
 */
function applyDiscount(price, discount) {
  const cost = moneyToFloat(price);
  const savings = percentToFloat(discount);
  return cost - cost * savings;
}

export { applyDiscount }

Easy enough so far. Note that moneyToFloat() and percentTofloat both return the value, not the container.

Functional Style#

import { Box } from '../lib/box';
import { moneyToFloat } from './money-to-float-v2';
import { percentToFloat } from './percent-to-float-v2';

/**
 * Applies a dicount to the given price.
 *
 * ASSUME: price and discount are proper price and discount strings,
 * e.g: '$49.99' and '20%'.
 *
 * @sig String String -> Number
 *
 * @param {string} price
 * @param {string} discount
 * @return {number}
 *
 * @example
 * applyDiscount('$100', '20%');
 * // → 80
 *
 * @example
 * applyDiscount('$50.00', '5%');
 * // → 47.5
 */
function applyDiscount(price, discount) {
  return Box(moneyToFloat(price))
    .fold(cost => Box(percentToFloat(discount))
      .fold(savings => cost - cost * savings));
}

export { applyDiscount }

In the video, Brian decides to change moneyToFloat() and percentTofloat so they return the container, not the value. In that case, the applyDiscount() implementation can be done like this:

function moneyToFloat(price, discount) {
  return moneyToFloat(price)
    .fold(cost => percentToFloat(discount)
      .map(savings => cost - cost - savings));
 }

Both ways are OK. Just wanted to show that we don’t need to change existing functions to implement applyDiscount().