Using pipe and compose to improve procedural code

Simon Schwartz
Jul 4, 2019 · 6 min read
pipes in real life… courtesy https://bksas.no/en/produkt/instrument-piping/

This article is about using pipe and compose to improve the way we write procedural code.

What is procedural code you ask?

Procedural code is sequence based code. Think of it as a list of functions executed one after the other to produce some sort of output. Let’s look at an example. Given an array of numbers, we want to remove any numbers less than 10 and sort the numbers from lowest to highest.

const numbers = [1, 4, 100, 2, 47, 20, 187];const result = numbers
.filter(number => number > 10)
.sort((a, b) => a - b);
// result = [20, 47, 100, 187]

This is a nice way to write procedural code. Unfortunately, we cannot chain all our JavaScript functions together this easily.

JavaScript only lets us chain filter() and sort() together because it knows that these functions will always:

  • accept a single argument of an array

When we write our own functions, JavaScript has no idea if the return type of our function will line up with another functions input type. JavaScript does not trust us, not even for a second. So it doesn’t let us chain functions together. This is actually a good thing. It stops us from writing buggy software.

What are pipe and compose?

pipe and compose are functions that make it easy for us to chain functions together. They help us to write simpler, more declarative and more scalable procedural code.

Let’s look under the hood.

const pipe = (...fns) => a => fns.reduce((b, f) => f(b), a);

So pipe is a curried function (its takes multiple arguments, one at a time). The first argument it takes is a list of functions. These are the sequence of functions we want to run, one after the other. The second argument is the value for the argument of the first function.

Internally, pipe uses reduce to run each function in the list, passing the starting value a to the first function in the list. It then returns the result of the first function to the next function and so on.

pipe(
firstFunction,
secondFunction,
thirdFunction
)('argument passed to firstFunction');

compose is the same as pipe, but is reduces right to left…

const compose = (...fns) => a => fns.reduceRight((y, f) => f(y), a);compose(
thirdFunction,
secondFunction,
firstFunction
)('argument passed to firstFunction'))

The rules

Remember what I said about JavaScript not trusting us? Well we can still write horrible bugs using pipe if we aren’t careful. Here are the rules we need follow to make things work.

  1. The first function may have any arity(accept any number of arguments)

Let’s look at a real life example to show how we can use pipe. This example is very similar to something I worked on with a TV broadcasting company. You can also view the full and final demo in code sandbox.

A quick demo

We have an API which returns a list of shows for a given channel. It looks something like this:

{
channelCode: 'output-2',
shows: [
{
name: 'Spongebob',
start: 1561867200000,
end: 1561869000000,
},
...121 more shows
]
}

For our app, we need to do some transformations to the payload we receive:

  • the channelCode represents the physical output location of the video stream. We need to map this to the actual name of the TV channel. eg output-2 maps to kids.

Let’s write some functions that do the required transformations as stated above. It doesn’t matter too much about how these functions work. They take our API response and return a new copy of the API response, but with some transformation made to it.

// Add duration(ms) for all shows in API response
const addDuration = response => {
const formattedShows = response.shows.map(show => ({
duration: show.end - show.start,
...show,
}));
return {
...response,
shows: formattedShows,
};
};
// Add channel name for all shows in API response
const addChannel = response => {
const channelMap = [
{ code: 'output-1', name: 'music' },
{ code: 'output-2', name: 'kids' },
{ code: 'output-3', name: 'news' },
];
const match = channelMap
.find(channel => channel.code === response.channelCode)
return {
channelName: match.name,
...response,
};
};

Now we need to write some procedural code that will perform the sequence of transformations on our api response.

Without pipe 😓

// Fetch our shows data
const response = api.getShows();
const parsed = JSON.parse(response);
const withDuration = addDuration(parsed);
const formattedShows = addChannel(withDuration);

This approach works. It’s valid code, but it has some issues.

  • It requires us to create, name and maintain variables whose sole purpose is to store a functions return value

Let’s improve this code using pipe!

With pipe 😎

import { pipe } from 'rambda';const response = await fetch(api.com/output-2/shows);const formattedShows = pipe(
JSON.parse,
addDuration,
addChannel
)(response);

This approach is better for a few reasons.

  1. We are not creating unnecessary variables. This reduces the noise and cognitive load required to read the code.

So our pipe function is working great. Then the boss tells us about a new feature…

Refactor time

This new feature we are working on requires us to refactor the addChannel() function. The function needs to be able to accept different values for channelMap. We can solve this by adding a second argument to the addChannel() function.

// Add channel name for all shows in API response
const addChannel = (channelMap, response) => {
const match = channelMap
.find(channel => channel.code === response.channel)
return {
channelName: match.name,
...response,
};
};

The remaining functions must be unary(accept only a single argument)

Well that’s it… we can’t use pipe or compose anymore…

Or can we?

Let’s use the power of currying to let us pass multiple arguments to a function in our pipe. Currying is where we create functions that accept multiple arguments, one at a time.

This means we would now call the addChannel function like this:

addChannel(channelMap)(response);

Currying is a useful technique when we are using composition techniques that require functions to be unary, eg have a single argument.

First we curry the addChannel function

// Add channel name for all shows in API response
const addChannel = channelMap => response => {
const match = channelMap
.find(channel => channel.code === response.channel)
return {
channelName: match.name,
...response,
};
};

Now we can create a re-usable pipe function like so:

import { pipe } from 'rambda';const CHANNEL_MAP = [
{ code: 'output-1', name: 'music' },
{ code: 'output-2', name: 'news' },
{ code: 'output-3', name: 'kids' },
];
const CHANNEL_MAP_BRANDED = [
{ code: 'output-1', name: 'MTV' },
{ code: 'output-2', name: 'Sky' },
{ code: 'output-3', name: 'Nickelodeon' },
];
const response = await fetch(api.com/output-2/shows);const formatShows = (channelMap, payload) => pipe(
JSON.parse,
addDuration,
addChannel(channelMap)
)(payload);
const formattedShows = formatShows(CHANNEL_MAP, response);
const brandedShows = formatShows(CHANNEL_MAP_BRANDED, response);

This works because addChannel(CHANNEL_MAP) returns a function with the value for CHANNEL_MAP in its closure scope. Still doesn’t make any sense? Read my more in depth article on currying — Why the fudge should I use currying?

Check out the full demo in code sandbox.


Thanks for reading — I hope you have found this article helpful!

DailyJS

JavaScript news and opinion.

Thanks to Dominik Wilkowski

Simon Schwartz

Written by

Developer from Sydney, Australia. Aspiring to communicate complex topics in simple ways. Engineer at Thinkmill

DailyJS

DailyJS

JavaScript news and opinion.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade