Four Ways to Compose Synchronous Functions in JavaScript: Nest, Promise, Compose, & Pipeline

Introduction

Functional Programming is built around composing pure functions. Composing functions means taking all those useful functions you wrote and using them together to build more powerful functions, and even applications. This article will cover the 4 main ways to do that with synchronous code which includes the new pipeline operator. A future article will handle asynchronous options as well as dealing with partial applications and curried functions. If you’d like to play with the examples yourself, I have a Code Sandbox setup with basic and advanced examples.

Preface: The Parsing Code

The four examples below will parse the same data composing the same functions. Let’s define that data and parsing code here first so you can see the various ways of combining functions.

Logging

To save us some typing, we’ll store log as our console.log function:

const log = console.log

People JSON String

Our data is a JSON String list of 2 people and 1 dog.

const peopleString = `[
	{
		"firstName": "jesse",
		"lastName": "warden",
		"type": "Human"
	},
	{
		"firstName": "albus",
		"lastName": "dumbledog",
		"type": "Dog"
	},
	{
		"firstName": "brandy",
		"lastName": "fortune",
		"type": "Human"
	}
]`

Parsing Function

Our mostly pure parsing function will take a string and parse it to a JSON Object:

const parsePeople = str =>
	JSON.parse(str)

Filter Humans

We need 2 functions to filter out the dog. The first is a predicate function that ensures a person’s type is “Human”.

const filterHuman = person =>
	person.type === "Human"

So filterHuman({type: 'Human'})would return trueand filterHuman({type: 'Dog'})would return false.

The 2nd function will use that predicate in the Array.filter function so only humans will remain.

const filterHumans = peeps =>
	peeps.filter(filterHuman)

Format Names

We want to format the names so instead of 2 separate firstNameand lastName variables, instead they’re combined into a single string.

const formatName = human =>
	`${human.firstName} ${
		human.lastName
	}`

We can use it to take formatName({firstName: 'bruce', lastName: 'campbell'}) which will result in “bruce campbell”.

Next is to use that function on everyone in the list using map:

const formatNames = humans =>
	humans.map(formatName)

Fixing The Name Case

The 4th and final thing to do is fix the name casing. All the names are lowercased and we want it to be proper with the first and last name having the first character uppercased. The easiest way is to simply us Lodash’ startCase function to do it for us.

const startCaseName = name =>
	startCase(name)

That will take startCaseName('bruce campbell') and produce “Bruce Campbell”. Taking that function we can again apply it to every person in the list using map:

const startCaseNames = humans =>
	humans.map(startCaseName)

Nesting

The most common way to compose functions is to nest them. If we want to parse the JSON, we’ll first call parsePeople:

const parse1 = str =>
    parsePeople(str)

That’ll give us our Array of people Objects, so we can next filter out the humans:

const parse1 = str =>
    filterHumans(parsePeople(str))

The filterHumansfunction will take in whatever parsePeople(str)returns. Next is to format all the human names:

const parse1 = str =>
    formatNames(filterHumans(parsePeople(str)))

Two nested functions is where people start to draw the line on things being unreadable, similar to nested if statements. However, there is only 1 more left, and that’s fixing the case of the names:

const parse1 = str =>
    startCaseNames(formatNames(filterHumans(parsePeople(str))))

Calling parse1(peopleString) will result in:

["Jesse Warden", "Brandy Fortune"]

One thing you can possibly do to make it more readable is to treat it like nested if blocks and space it appropriately:

const parse1 = str =>
	startCaseNames(
		formatNames(
			filterHumans(
				parsePeople(str)
			)
		)
	)

Promise

Given each of these operations is done one after the other, you can use Promises. They have the nice feature in their then statement, you can return a value, not just a Promise. Normally you’ll see people return a Promise:

const twoThings = () =>
    Promise.resolve(1)
    .then(result => Promise.resolve(result + 1))
twoThings()
.then(result => log("result:", result) // 2

However, once you’re in a Promise chain, as long as it’s not an Error, you can return whatever you want and it’ll resolve into the next then. Rewriting the same function above using that would be:

const twoThings = () =>
    Promise.resolve(1)
    .then(result => result + 1)
twoThings()
.then(result => log("result:", result) // 2

Or just defining the intermediate function separately:

const add1 = result =>
    result + 1
const twoThings = () =>
    Promise.resolve(1)
    .then(add1)

Which means, we can take our nested function:

const parse1 = str =>
    startCaseNames(formatNames(filterHumans(parsePeople(str))))

And convert it to a more readable Promise version, even though it’s synchronous code:

const parse2 = str =>
    Promise.resolve(str)
        .then(parsePeople)
        .then(filterHumans)
        .then(formatNames)
        .then(startCaseNames)

The downside is it still has to be called like a normal asynchronous Promise:

parse2(peopleString)
.then(log) // ["Jesse Warden", "Brandy Fortune"]

However, it does have the advantage of being a bit easier to read, easier to follow in terms of what is happening when and in what order. Additionally, Promises have built-in try/catch so the error handling is in one place. Finally, because of the chaining, you can quickly comment out a section of the chain to debug what’s going up to a certain point:

const parse2 = str =>
    Promise.resolve(str)
        .then(parsePeople)
        .then(filterHumans)
        //.then(formatNames)
        //.then(startCaseNames)

You can also do a tapfunction to determine how things are progressing up to a certain point as well. For now, we’ll use a modified logfunction to take a value, log it out, then return whatever you were passed:

const tap = (...args) => {
    log(args)
    return Promise.resolve.apply(Promise, args)
}

Notice below, we can use tap to log things out, or use log manually if we want more detail:

const parse2 = str =>
    Promise.resolve(str)
        .then(tap)
        .then(parsePeople)
        .then(tap)
        .then(filterHumans)
        .then(humans => log("humans:", humans) || humans)
        .then(formatNames)
        .then(tap)
        .then(startCaseNames)
        .then(final => log("final is:", final))

Flow / Compose

Ramda uses compose and Lodash calls it flow. They take a bunch of functions you want piped together and give you a new one. Taking our nested function:

const parse1 = str =>
    startCaseNames(formatNames(filterHumans(parsePeople(str))))

Which is parsing people JSON, then filtering the dog out, then formatting the names as strings, and finally fixing the casing. We can do that, in order like we did in the promise. Using Lodash’ flow to make 1 function of all them:

const supaParse =
    flow([
        parsePeople
        , filterHumans
        , formatNames
        , startCaseNames
    ])

However, instead of storing it as supaParse, we can you can just use it inline.

const parse3 = str =>
	flow([
		parsePeople
		, filterHumans
		, formatNames
		, startCaseNames
	])(str)

Calling parse3(peopleString)will result in ["Jesse Warden", "Brandy Fortune"]. Like the Promiseexample, it’s easier to read and follow the order of what’s happening. Unlike the Promise, it’s synchronous, so there is no need to worry about Promise chains, async/await, etc. However, error handling is on you, and some of your intermediate functions may not know how to handle errors as their inputs when they were expecting a string of Arrays like startCaseNamesfor example.

Like the Promiseexample, you can comment out parse of the sequence to better debug how your data is being modified up to a certain point:

const parse3 = str =>
	flow([
		parsePeople
		, filterHumans
		// , formatNames
		// , startCaseNames
	])(str)

You can log out each part using a modified tap:

const tap = arg => {
    log(arg)
    return arg
}

And then use it:

const parse3 = str =>
	flow([
		parsePeople
		, tap
		, filterHumans
		, tap
		, formatNames
		, arg => log("after format names:", arg) || arg
		, startCaseNames
	])(str)

Pipeline Operator

There is a proposal to add a pipeline operator to JavaScript like F#, Elm, and Elixir have. At the time of this writing, you can play with 2 of the 3 proposed styles: Minimal, F#, and Smart in Babel 7.2. I’ll show just Minimal and Smart below for now.

Taking our existing nested function:

const parse1 = str =>
    startCaseNames(formatNames(filterHumans(parsePeople(str))))

We can use the pipeline operator |> to accomplish the same thing:

const parse4 = str =>
    parsePeople(str)
    |> filterHumans
    |> formatNames
    |> startCaseNames

Calling parse4(peopleString)will result in ["Jesse Warden", "Brandy Fortune"].

Like the others, you can comment out the parts at the bottom to see how things are progressing:

const parse4 = str =>
    parsePeople(str)
    |> filterHumans
    // |> formatNames
    // |> startCaseNames

You can re-use the same tap function as flow / compose:

const parse4 = str =>
    parsePeople(str)
    |> tap
    |> filterHumans
    |> tap
    |> formatNames
    |> (arg => log("after format names:", arg) || arg)
    |> startCaseNames

Conclusions

When composing pure functions together to build more specific functions, hopefully now you can see you have some options. While nested functions work, like nested if’s, they’re hard to read and reason about. Promises are nice and flexible between sync and async with built-in error handling, but sometimes adding that level of complication isn’t what you want. The flow from Lodash and compose from Ramda are a lot nicer and more specific, but all the error handling is on you. The pipeline operator is probably the easiest to read, but has the same problem with flow / compose around error handling.

Leave a Reply

Your email address will not be published. Required fields are marked *