DEV Community

Cover image for Exploring DO Notation in JS
Tracy Gilmore
Tracy Gilmore

Posted on

Exploring DO Notation in JS

Introduction

In the pure FP language Haskell (and others) there is a composing mechanism known as “Do Notation”. In this post we will be mimicking this mechanism using JavaScript, not to poke fun at it but to investigate it and add it to our solution toolkit.

Haskell Do Notation

The Do Notation is typically used to combine Monads to perform operations such as IO (Input/Output), but we will not be discussing that subject here. It is sufficient to know the following:

  • Monads are often used to isolate operations that result in side-effects in order to protect the rest of the application from unexpected consequences.
  • Monads behave like a simple (pure) function, which is what we will be using.
  • The functions return a value from an operation that expects to be supplied with a single value; although the value could be complex (object and/or array).

There is a similar approach for combining operations called pipelines. See the footnote to discover more about the TC39 proposal to bring pipes to JS. In both cases the composition takes the form of a chain of operations with the output of one being the input of the next.


The use case

In order to demonstrate and contrast the two approaches we could do with a simple process to exercise the techniques. We will be using how we can convert temperatures between degrees Celsius (C) and degrees Fahrenheit (F). If you are familiar with this process feel free to skip this section. If you are not, and especially if you are uncomfortable with mathematics, I promise to take you through the calculations slowly.

Converting temperatures between C and F

Believe it or not, the mathematics for this is simple and only uses the sort of operations found on a basic calculator: addition (+), subtraction(-), multiplication(x, although in computing we use the symbol *) and division (/).

The two scales, C and F, are consistent and can be represented as a straight line on a graph. Keep with me and I will explain.

C vs. F Graphs

If we take water, freeze it solid and measure its temperature we can get two numbers. When the thermometer is in F mode we get 32, when in C mode we get zero. If we boil water and measure its temperature we again get two values: 212°F and 100°C. This is represented on the above graph as the red line. The F values run up and down the left (vertical) axis and the C scale runs along the bottom horizontal axis.

We can use the graph to convert Fahrenheit to Celsius by simply finding the temperature on the left axis and following a path horizontally to the red diagonal line. At the point we hit the line, trace a vertical path down to the bottom axis where we will find the Celsius temperature. Our functions will do something similar to this process.

If we adjust the F temperature by subtracting 32, making frozen water also 0F, the diagonal line now passes through 0 on both axes. Shown as the green line but notice the red and green lines remain parallel, like a pair of train tracks, the distance between them remains the same from one end to the other and they never cross.

However, boiling water would become 180°F but still 100°C, the slope of the diagonal line remains the same. This is important because it means as C changes F also changes, not by the same amount but by a consistent rate or ratio. In fact as C goes from freezing (0) to boiling (100), the temperature in F increases by 180. At 50°C the point on the green line (F - 32) is 90. To get the actual temperature in F (red line) we need only add the 32 back on = 90 + 32 = 112. The relationship of the slope is 100:180, which is also 50:90 (as shown above) and 5:9. For every increase in C by 5 degrees, F will increase by 9 degrees and this is consistent.

Now for the formulas/equations (recipes) for C to F and F to C, with examples, which is just another way of showing what we found out above.

((C * 9) / 5) + 32 gives us F

((F - 32) * 5) / 9 gives us C
Enter fullscreen mode Exit fullscreen mode

Let’s convert Celsius to Fahrenheit, and back again

Boiling point: 100°C * 9 = 900
    900 / 5 = 180
        180 + 32 = 212°F

Freezing point: 0°C * 9 = 0
    0 / 5 = 0
        0 + 32 = 32°F
Enter fullscreen mode Exit fullscreen mode

Now for converting F to C,

Boiling point: 212°F - 32 = 180
    180 * 5 = 900
        900 / 9 = 100°C

Freezing point: 32°F - 32 = 0
    0 * 5 = 0
        0 / 9 = 0°C
Enter fullscreen mode Exit fullscreen mode

There is a third number on the far left end of the red line that is interesting. What makes it interesting is, it is the same value in C and F, that is -40degrees.

-40°C * 9 = -360
    -360 / 5 = -62
        -62 + 32 = -40°F
Enter fullscreen mode Exit fullscreen mode

Also,

-40°F - 32 = -72
    -72 * 5 = -360
        -360 / 9 = -40°C
Enter fullscreen mode Exit fullscreen mode

In all of the calculations above we used only four simple operations:

  • Addition (+ 32)
  • Subtraction (- 32)
  • Multiply (* 9 and * 5)
  • Divide (/ 5 and / 9)

Combining three of these operations in just the right order (as illustrated above) will provide us with the calculations we need. If each operation is a simple function, combining them is the same as composing the functions to create a temperature conversion function.


The Canonical imperative approach

It is relatively straightforward to code the above calculations in the imperative (function/method) approach. In fact some of the brackets in the code could be removed but have been included to align with the above explanation and later code.

/* canonical.js */

import runTestCases from './testcases.js';

const INTERCEPT = 32;
const CELSIUS_DELTA = 5;
const FAHRENHEIT_DELTA = 9;
const SLOPE = CELSIUS_DELTA / FAHRENHEIT_DELTA;

function celsiusToFahrenheit(c) {
    return c / SLOPE + INTERCEPT;
}
function fahrenheitToCelsius(f) {
    return (f - INTERCEPT) * SLOPE;
}

runTestCases(celsiusToFahrenheit, fahrenheitToCelsius);
Enter fullscreen mode Exit fullscreen mode

Notice the import at the top of the above code fragment. It is used to bring in a testing capability, which is exercised on the last line by calling the runTestCases function. In this example it will present the two temperature functions with the following test cases:

/* Fragment of the testcases.js file (testcase 1) */

celsiusToFahrenheit: [
    { input: 100, expected: 212 },
    { input: 0, expected: 32 },
    { input: -40, expected: -40 },
],
fahrenheitToCelsius: [
    { input: 212, expected: 100 },
    { input: 32, expected: 0 },
    { input: -40, expected: -40 },
],
Enter fullscreen mode Exit fullscreen mode

The tests call the function using the input value and then compares the output against the expected value. Here are the results.

Table of test results of the Canonical implementation

In the next example we continue in the imperative coding style but in a more elaborate way en-route to the subject of this post.

/* imperative.js */

import runTestCases from './testcases.js';

function add(m, n) {
    return m + n;
}
function sub(m, n) {
    return m - n;
}
function mul(m, n) {
    return m * n;
}
function div(m, n) {
    return m / n;
}

function celsiusToFahrenheit(c) {
    return add(div(mul(c, 9), 5), 32);
}
function fahrenheitToCelsius(f) {
    return div(mul(sub(f, 32), 5), 9);
}

runTestCases(celsiusToFahrenheit, fahrenheitToCelsius);
Enter fullscreen mode Exit fullscreen mode

We exercise the functions in the same way as before and they produce the same results. The big difference here though is the way the mathematical operations are prepared and used. Notice how the div and mul functions are used twice. Also notice how the temperature conversion functions are composed of simple mathematical operations.


Going functional

The next example adopts a more 'functional' style of coding, as in Functional Programming, instead of the procedural and other imperative style demonstrated so far.

/* functional.js */

import runTestCases from './testcases.js';

import { specificOperations } from './operations.js';

const { add32, div5, mul9, div9, mul5, sub32 } = specificOperations;

// Conversion functions
function celsiusToFahrenheit(c) {
    return add32(div5(mul9(c)));
}
function fahrenheitToCelsius(f) {
    return div9(mul5(sub32(f)));
}

runTestCases(celsiusToFahrenheit, fahrenheitToCelsius);
Enter fullscreen mode Exit fullscreen mode

Testing of the above code fragment is exactly the same as before but this time we are also importing an object containing specific operations from the operations module (described below). These functions make it more obvious what is being done and reduce attention to how it is being done, which is the declarative nature of Functional Programming (FP). However, the way we compose the operations to for the conversion functions remains more imperative than declarative.

Generic and Specific Operations

The mathematical functions that underpin the operations are very simple but function composition would typically be more involved.

/* operations.js */

// Generic operations
function addM(m) {
    return n => n + m;
}
function subM(m) {
    return n => n - m;
}
function mulM(m) {
    return n => n * m;
}
function divM(m) {
    return n => n / m;
}

export const genericOperations = {
    addM,
    subM,
    mulM,
    divM,
};

// Specific operations
const add32 = addM(32);
const div5 = divM(5);
const mul9 = mulM(9);
const div9 = divM(9);
const mul5 = mulM(5);
const sub32 = subM(32);

export const specificOperations = {
    add32,
    div5,
    mul9,
    div9,
    mul5,
    sub32,
};
Enter fullscreen mode Exit fullscreen mode

In the above module two objects are exported genericOperations, specificOperations, with the specificOperations based on the genericOperations. The genericOperations are based on the four basic mathematical functions using the FP technique known as currying so the parameters can be supplied independently. This enables the specificOperations to employ another FP technique known as partial application where the first argument is provided (bound to the first parameter) to generate a specialised function (operation). See this article for a more complete explanation of the techniques.


Do Notation in JS

In some FP languages the Do Notation is “idiomatic” meaning it is built into the language. Although JS comes with some features taken from the FP school, Do Notation is not one of them, so we have to recreate it ourselves. There is some new syntax in the "pipeline" but it is a little way off yet (see footnote 2).

In the next seven code examples we will be developing a set of functions to simulate Do Notation; exploring fragments from the do-notation.js module as we go.

"Cracker" diagrams

As an alternative way of describing each of the following examples, I have devised a way of illustrating the functionality that bears an uncanny resemblance to Christmas Crackers. If you are not familiar with the novelty, you might find this Wikipedia page of interest.

The diagrams flow from left to right and employ the following symbols.

Cracker diagram symbol key

The diagrams attempt to show how data supplied to the composed function (at the left) flows through a series of functions to produce the output value (at the right).

Example cracker diagram


Example 1: DOing it with specific functions

Example One

The 'cracker' diagram above is a depiction of the two DO compositions below. Observer how they take in a numeric value, pass through a series of three specific functions (composed into a single DO function) and return a numeric value as output. The previous sentence is true for both the diagram and the source code.

/* do-mk1.js */

import runTestCases from './testcases.js';

import { DO } from './do-notation.js';

import { specificOperations } from './operations.js';

const { add32, div5, mul9, div9, mul5, sub32 } = specificOperations;

// Conversion functions
const celsiusToFahrenheit = DO(mul9, div5, add32);

const fahrenheitToCelsiusOperations = [sub32, mul5, div9];
const fahrenheitToCelsius = DO(fahrenheitToCelsiusOperations);

runTestCases(celsiusToFahrenheit, fahrenheitToCelsius);
Enter fullscreen mode Exit fullscreen mode

In the above code fragment the specific functions are composed using the DO function. These will operate on an initial numeric input to produce a numeric output but we will expand on this later, but first we will define the DO function.

/* Fragment of do-notation.js */

export function DO(...fns) {
    return data => 
        fns.flat().reduce((output, fn) =>
            fn(output), data);
}
Enter fullscreen mode Exit fullscreen mode

The DO function is quite simple. It uses the rest syntax to accept a list of functions as a single array parameter. It returns a function that expects a single data parameter. When called, the returned function 'pipes' the input from one function to another, using the reduce method, with the final result being the output of the DO function.

Example 2: DOing it with generic functions

Example Two

This example is virtually identical to the previous but uses the genericOperations. This means there are fewer operations imported but when called they have to be supplied with the initial argument to specilise them.

/* Fragment of do-mk2.js */

const { addM, subM, mulM, divM } = genericOperations;

// Conversion functions
const celsiusToFahrenheit = DO(
    mulM(9), 
    divM(5), 
    addM(32)
);

const fahrenheitToCelsiusOperations = [
    subM(32),
    mulM(5),
    divM(9)
];
const fahrenheitToCelsius = DO(fahrenheitToCelsiusOperations);
Enter fullscreen mode Exit fullscreen mode

Example 3: Using formatted (string) input

Example Three

In the third example we will use a string to represent the input temperature but this is just a stepping-stone so our output will still be numeric. The primary purpose of this example is to demonstrate the type of data input can be different from that of the output.

/* Fragment of the testcases.js file (testcase 2) */

celsiusToFahrenheit: [
    { input: '100°C', expected: 212 },
    { input: '0°C', expected: 32 },
    { input: '-40°C', expected: -40 },
],
fahrenheitToCelsius: [
    { input: '212°F', expected: 100 },
    { input: '32°F', expected: 0 },
    { input: '-40°F', expected: -40 },
],
Enter fullscreen mode Exit fullscreen mode

The test results report is slightly different.

Example 3: Test results

Notice the input value is a string that combines the numeric value and the scale (C or F) separated by the degree symbol (°). This means the numeric value will need to be extracted from the input string as part of the DO composition using the following function.

function extractTemp(tempStr) {
    return parseInt(tempStr, 10);
}
Enter fullscreen mode Exit fullscreen mode

The conversion function will be constructed as follows:

/* Fragment of do-mk3.js */

// Conversion functions
const celsiusToFahrenheit = DO(
    extractTemp, 
    mulM(9), 
    divM(5), 
    addM(32)
);

const fahrenheitToCelsiusOperations = [
    extractTemp,
    subM(32),
    mulM(5),
    divM(9),
];

const fahrenheitToCelsius = DO(fahrenheitToCelsiusOperations);
Enter fullscreen mode Exit fullscreen mode

Notice the combination of function references and function calls (that return a function reference) in the list of functions of the DO instruction. Ideally, we also like the output to be a string so on the example four.

Example 4: Conditional processing of formatted input & output

Example Four

In this example our conversion functions will take in data as a string and produce the output as a string.

/* Fragment of the testcases.js file (testcase 3) */

celsiusToFahrenheit: [
    { input: '100°C', expected: '212°F' },
    { input: '0°C', expected: '32°F' },
    { input: '-40°C', expected: '-40°F' },
],
fahrenheitToCelsius: [
    { input: '212°F', expected: '100°C' },
    { input: '32°F', expected: '0°C' },
    { input: '-40°F', expected: '-40°C' },
],
Enter fullscreen mode Exit fullscreen mode

Example 4 - String to string test results

At this point we will introduce a mechanism for conditional execution (a.k.a. branching) through the IF function in conjunction with the isCelsius predicate function defined below.

/* Fragment of do-mk4.js */

function isCelsius(tempStr) {
    return tempStr.at(-1).toUpperCase() === 'C';
}
function convertToString(scale) {
    return n => `${n}°${scale.toUpperCase()}`;
}
Enter fullscreen mode Exit fullscreen mode

The compositions now look like this.

// Conversion functions
const celsiusToFahrenheit = [
    extractTemp,
    mulM(9),
    divM(5),
    addM(32),
    convertToString('F'),
];

const fahrenheitToCelsius = [
    extractTemp,
    subM(32),
    mulM(5),
    divM(9),
    convertToString('C'),
];

const convertTemperature = DO(
    IF(isCelsius,
        DO(celsiusToFahrenheit),
        DO(fahrenheitToCelsius)
    )
);
Enter fullscreen mode Exit fullscreen mode

The above code wraps the IF function in a DO operation but as this is the only task in the composition, and the IF call returns a function, it could be executed directly. The do-notation IF function is very simple, defined as follows.

export function IF(condition, doTrue, doFalse) {
    return data => (condition(data)
        ? doTrue(data)
        : doFalse(data)
    );
}
Enter fullscreen mode Exit fullscreen mode

The above function accepts three parameters, all functions, and returns a new function that takes a single input as part of the DO composition. The first 'condition' is what is known as a predicate function because it converts its input into a Boolean output (true or false). This decides which of the next two functions will be executed. In our example, the condition identifies the scale of the input (C = true, or F = false) and performs the appropriate doTrue for celsiusToFahrenheit or doFalse for fahrenheitToCelsius.

Example 5: DO_IF_THEN_ELSE

Example Five

The do notation IF function is a little less readable than its imperative equivalent so in this example we make use of function chaining to make a more "readable" mechanism.

export function DO_IF(condition) {
    return {
        THEN_DO: doTrue => ({
            ELSE_DO: doFalse => 
                data => DO(condition(data)
                    ? doTrue 
                    : doFalse)(data),
        }),
    };
}
Enter fullscreen mode Exit fullscreen mode

The implementation is a little more complicated, which is often the trade-off, but in exchange we abstract the complexity from where we want to use it.

/* Fragment of do-mk5.js */

const convertTemperature = DO_IF(isCelsius)
    .THEN_DO(celsiusToFahrenheit)
    .ELSE_DO(fahrenheitToCelsius);
Enter fullscreen mode Exit fullscreen mode

At this point which approach to conditionals, I think, is a matter of personal preference as they have the same effect. Another common processing mechanism we might want to employ is loops, so here is our next example.

Example 6: DO_WITH

Example Six

In this example we want to process multiple input values in a single call. Here are the test cases presented as properties of an object we will be supplying to the DO composition. The name of each property is the input value of a single calculation, the property value is the expected output for comparison.

/* Fragment of the testcases.js file (testcase 5) */

{
    '100°C': '212°F',
    '0°C': '32°F',
    '-40°C': '-40°F',
    '212°F': '100°C',
    '32°F': '0°C',
    '-40°F': '-40°C',
}
Enter fullscreen mode Exit fullscreen mode

After passing the above values through the DO process we get the following results.

Example 6 - Single run test results

For comparison, here is the source code.

/* Fragment of do-mk6.js */

const extractInputs = _ =>
    Object.entries(_).map(([input, expected]) => ({
        input,
        expected,
    }));

const convertInput = _ => ({ ..._, actual: 

convertTemperature(_.input) });

const evaluateResult = _ =>
    ({ ..._, result: _.expected === _.actual });

console.table(
    DO_WITH(
        extractInputs, 
        convertInput, 
        IDENTITY, 
        evaluateResult
    )(testCases[4])
);
Enter fullscreen mode Exit fullscreen mode

Example 7: The finale - Object processing

Example Seven

Here we are with the final example in which we process complex data held in an object. To do this we will be employing more of a Monad-ic style through the processObject function that will be used to wrap the partially applied, mathematical operations supplied by the genericOperations object. The wrapper is used as an adaptor that makes the input data object into something the specialised functions can work with. It also takes the calculated result and converts it back into another object, ready for the next call.

function processObject(func) {
    return tempObj => ({
        num: func(tempObj.num),
        scale: tempObj.scale,
    });
}

function extractTemp(tempStr) {
    const [temp, scale] = tempStr.split(/°/);
    return { num: +temp, scale };
}

function convertToString(tempObj) {
    const newScale = isCelsius(tempObj) ? 'F' : 'C';
    return `${tempObj.num}°${newScale}`;
}
Enter fullscreen mode Exit fullscreen mode

The extractTemp function converts the test case string into the object we will be passing through the DO process. Conversely, the convertToString will encode the finished object back into a string for validation and presentation. However, the conversion functions do look a little odd with all the processObject wrappers.

// Conversion functions
const celsiusToFahrenheit = DO(
    processObject(mulM(9)),
    processObject(divM(5)),
    processObject(addM(32))
);

const fahrenheitToCelsius = DO(
    processObject(subM(32)),
    processObject(mulM(5)),
    processObject(divM(9))
);
Enter fullscreen mode Exit fullscreen mode

But the convertTemperature function does not look vastly different from that in example four.

const convertTemperature = DO(
    extractTemp,
    IF(isCelsius,
        celsiusToFahrenheit, 
        fahrenheitToCelsius),
    convertToString
);
Enter fullscreen mode Exit fullscreen mode

In Summary

In my mind, and I am sure it has been said by others, Functional Programming is all about composition. Combining smaller (and simpler) functions together into a larger, more complicated (and usually dedicated) function so they execute all at the same time.

JavaScript is gradually acquiring FP-style capability that can greatly enhance your tool kit. You don't have to use FP or OOP entirely. A developer should strive to use the most appropriate tools to formulate the solution required for the problem at hand. I have used FP and OOP features in combination to great effect and produced solutions that are easier to understand, test and maintain.


Footnotes

  1. It has to be said that the Do Notation in Haskell is not universally loved, as indicated in this page of the Haskell documentation.
  2. There is an ECMAScript proposal for a pipe(line) operator syntax in JS, but it is only at stage 2. It is not quite the same as Haskell's Do Notation but is an alternative to some of the functionality we have implemented above.

Top comments (0)