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.
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.
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
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
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
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
Also,
-40°F - 32 = -72
-72 * 5 = -360
-360 / 9 = -40°C
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);
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 },
],
The tests call the function using the input value and then compares the output against the expected value. Here are the results.
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);
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);
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,
};
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.
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 1: DOing it with specific functions
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);
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);
}
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
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);
Example 3: Using formatted (string) input
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 },
],
The test results report is slightly different.
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);
}
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);
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
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' },
],
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()}`;
}
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)
)
);
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)
);
}
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
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),
}),
};
}
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);
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
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',
}
After passing the above values through the DO
process we get the following 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])
);
Example 7: The finale - Object processing
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}`;
}
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))
);
But the convertTemperature
function does not look vastly different from that in example four.
const convertTemperature = DO(
extractTemp,
IF(isCelsius,
celsiusToFahrenheit,
fahrenheitToCelsius),
convertToString
);
Errata
As identified by @javarobit in the comments below, for the isCelsius
function to work with the IF
construct, its implementation has to change to something like:
function isCelsius(tempObj) {
return tempObj.scale.toUpperCase() === 'C';
}
Thanks again to @javarobit.
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
- It has to be said that the Do Notation in Haskell is not universally loved, as indicated in this page of the Haskell documentation.
- 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 (6)
@javarobit, thanks for your comments. I will check over my article and make any corrections required. I really appreciate your contribution.
Tracy, thanks again for this article.
This is the complete code:
You are quite correct. After checking my own code I realised there was an omission in my article. If order for the
IF
function to work theisCelsuis
function has to change.In my test code the function has evolved to:
This is equivalent to your example.
And more.
This function (convertToString) depends on the order in which it is called. This is strange.
Maybe it would be better to set the scale when converting? :
It works, I checked.
The core idea is to pass data, as its form changes, through each of the operations of the DO instruction. Both the numeric and unit elements are required from start to finish to perform the correct conversion.
Hi, Tracy.
Great article.
But:
And
isCelsius breaks because it expects a string as input.
The new function isCelsius should probably be something like: