The World's Most Gentle Introduction Into Functional Programming
Dennis O'Keeffe
Posted on July 10, 2020
Your first dive into functional programming can be humbling. If you are familiar with object-oriented programming or any of the similar derivatives, then looking into different paradigms requires a reset of thinking and easing into foundational concepts.
Challenges are expected. We are in a trade that requires persistence in the art of learning.
In today's post, I am going to attempt to gently ease you into some core functional programming concepts by implementing two common utility functions: pipe and compose.
This post has no requirements other than an understanding of JavaScript and basic arithmetic. However, it is recommended that you understand how reduce
works.
To monad or not to monad
That is the question.
Now is the time to reference Lady Monadgreen’s curse which was brought into infamy by Douglas Crockford:
"Once you understand monads, you immediately become incapable of explaining them to anyone else"
You may or may not have heard about monads yet. I am going to heed to words of the curse and declare before starting that we will not be speaking about monads in this post.
It is a post for another time, but what I will say about them (and all the other quirky functional programming terms) is this: They are aptly named because of their roots in mathematics and Set Theory and you will naturally come to understand them by building a core foundation.
There. I said it. No crazy concepts. No monads, no beta reduction, no alpha equivalence. For now, they are all buzzwords. Let's get down to the basics.
Our first love addition
Let's go back to primary school mathematics and tell the tale of addition.
We understand that both the left-hand and right-hand side of 1 + 2 + 3 = 1 + 2 + 3
are equivalent.
In fact, we can simplify the right-hand side down further and say that 1 + 2 + 3 = 3 + 3
by adding the 1 + 2
that was on the right-hand side.
We didn't have to add the 1 + 2
, we also could have had 1 + 2 + 3 = 1 + 5
instead by add 2 + 3
from the right-hand side. We can bring simplify this down most to be 1 + 2 + 3 = 6
.
So far, we haven't broken any new ground. This is all common knowledge, and in fact, we probably would have skipped the middle stages that I spoke of and simplified straight to the answer 6. Have you stopped the think about why this is the case?
Talking through the properties of functional programming
Addition is a fantastic introduction into some of the core properties of functional programming as it exhibits them in a way that is easy to understand.
There are four important properties of functional programming:
- A function must be associative
- A function must be commutative
- A function must have an identity
- A function must be distributive
Let's explore these properties with our addition example and the power of language.
Associative
"To associate". A quick Google into the origins of "associate" give us this:
"...as a verb in the sense 'join within a common purpose'"
Using the history and meaning behind words can help open up our understanding of its application in programming and mathematics. It amazes me how apt the naming used in these fields are and we should thank those who came before us for such great attention to detail.
When we speak about addition being associative, we mean that we can "group" and "associate" variables with the addition operation together.
We state the law that (x + y) + z = x + (y + z)
. For functions that exhibit the associative property, this means that the order of operations will not change the outcome.
Looking at algebra can be cognitive load, but we already saw this in action from our trek down into addition memory lane when we stated this:
Given what we know about mathematics, we could write an add
function that is associative:
const add = (x, y) => x + y
// equivalent to figure 1b
add(add(1, 2), 3) === add(1, add(2, 3))
It may look strange as first looking at the equation, but for the left-hand side we can see that add(add(1, 2), 3)
will evaluate to add(3, 3)
and finally we get 6.
We can run similar logic on the other side to reduce our answer to 6.
Even if you are not familiar with hearing the term "associative law", you have been well acquainted most of your life!
Commutative
Going back to our origins of the term, commutative derives from "commute" or "to move around".
From its origins in late Middle English:
"In the sense 'interchange' (two things)"
To make sense of it, we could "interchange" 1 + 2
to be 2 + 1
.
From this, we can derive the commutative law:
Using our previous example of the add
function to see this play out:
const add = (x, y) => x + y
// equivalent to figure 2b
add(1, 2) === add(2, 1)
Simple as pie! The order doesn't matter for the operation when things are commutative.
Author's note: I can not make a pie, ergo I do not understand that saying.
For an example of something that is not commutative, take division. 1 / 2 != 2 / 1
. Division is a good counter-example for a number of functional laws.
Identity
When we speak of the identity property, I remember it to be that we wish for something to "keep its identity".
In addition, could you think of what you can add to another number for it to remain the same? If you said zero, then I dub thee an arithmetic wizard!
We know that anything in mathematics added to zero will result in itself. We managed "keep" the identity.
const add = (x, y) => x + y
// equivalent to figure 3b
add(1, 0) // 1
add(1000, 0) // 1000
add(-7, 0) // -7
What would be the identity property in multiplication? Understanding this can help you truly understand this property. Hint: it cannot be zero.
const multiply = (x, y) => x * y
multiply(1, 1) // 1
multiply(1000, 1) // 1000
multiply(-7, 1) // -7
If you said "one", then you are a true miracle maker! In all seriousness though, these trivial examples are fantastic examples that can help you remember these laws without the help of Google and Stack Overflow (or the Math Exchange equivalent). Feels good to know all this from understanding.
Distributive
Admittedly, the distributive property is the one that requires fractionally more brainpower than the others, but you will completely understand what it is after seeing it in action.
As for the definition:
"(of an operation) fulfilling the condition that, when it is performed on two or more quantities already combined by another operation, the result is the same as when it is performed on each quantity individually and the products then combined."
That sentence was more than a few words, so let's simplify it into a way we can understand:
The left and right-hand side are equivalent, and we've done this by abstracting the x
out and multiplying the y
and z
together.
This follows from algebraic principles that we understand through the order of operations. This property becomes incredibly important in functional programming for being able to re-arrange functions.
We won't deep dive into the implications of the distributive property in this post. Let's save that for when we have more tools under our belt.
Now that we have an understanding of the four base properties, let's switch gears and start talking about our add
function that we've been using so far.
Currying and uncurrying
In programming, we have the following definition for currying from our pal Wikipedia:
"...currying is the technique of converting a function that takes multiple arguments into a sequence of functions that each take a single argument."
Our add
function before took multiple arguments. The aim is for us to turn this into a "sequence of functions that each take a single argument".
This looks like the following:
// function that is not curried
const add = (x, y) => x + y
add(2, 1) // 3
add(2, 7) // 9
add(2, 10) // 12
// function that is curried
const curriedAdd = x => y => x + y
const addTwo(2)
add(2)(1) // 3
add(2)(7) // 9
add(2)(10) // 12
For the sake of completeness, doing the reverse and taking a function from the
curriedAdd
back to the form ofadd
above is the process of uncurrying.
The above looks kind of weird? Why would we ever want to write add(x)(y)
? Running the curried function like so is equivalent to running add(x, y)
with the uncurried version, but it gives us the powerful ability to partially apply values to functions and gives us some powerful tools for determinism.
Before we step into the power of partial application, I think it is time to start preparing our final voyage towards the goal of this tutorial.
Setting up the project
Let's start up a new project and start playing around with these figures. From your root project directory, run:
# init basic application -> use npm init -y if you prefer npm
yarn init -y
touch index.js index.test.js
# install a test suit to play along with
yarn add --dev jest
Yarn/NPM init
will generate the package.json
file, so let's update that with a test
script to run the Jest test suite.
{
"name": "build-your-own-pipe-compose",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"test": "jest"
}
}
Next, inside of index.js
, let's export an add
function. Armed with the understanding before about the add function, we will use our curried version:
const add = x => y => x + y
module.exports = {
add,
}
Next, in index.test.js
, let's import that function and write a simple test to check that math is still math:
const { add } = require("./index")
describe("curried add function", () => {
test("expect 1 + 2 = 3", () => {
expect(add(1)(2)).toEqual(3)
})
})
Run yarn test --watchAll
from the command line and we should be put into watch mode for our tests. If math is still math, you should be greeted with something similar to the following:
PASS ./index.test.js
curried add function
✓ expect 1 + 2 = 3 (3 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 0.407 s, estimated 1 s
Ran all test suites.
Watch Usage: Press w to show more.
Great! Math is still math!
Let's understand how partial application work.
Partial Application
Currying a function gives us the capability to applying arguments one at a time. This gives us the power to create deterministic, flexible functions that are easily testable and amazingly useful.
Let's create some trivial examples of some partially applied functions see them at work.
// index.js
const add = x => y => x + y
// partially applied functions
const addTwo = add(2)
const addTen = add(10)
// using the underscores for readability
const addTenMillion = add(10_000_000)
module.exports = {
add,
addTwo,
addTen,
addTenMillion,
}
Here, we are using the idea of partial application to apply 2, 10, and 10 million respectively. This locks in the fact that if we were to use the addTwo
function name to add the remaining argument, it would always deterministically add 2 to the argument.
Let me say that again.
If we were to use the addTwo
function name to add the remaining argument, it would always deterministically add 2 to the argument. This is the key takeaway from the concept of partial application.
Let's update our test suite in index.test.js
again:
const { add, addTwo, addTen, addTenMillion } = require("./index")
// random numbers to illustrate our point
const numbers = [1, 2, 5, 7, 8, 11]
describe("curried add function", () => {
test("expect 1 + 2 = 3", () => {
expect(add(1)(2)).toEqual(3)
})
test("expects addTwo to add two to any number", () => {
for (const number of numbers) {
expect(addTwo(number)).toEqual(number + 2)
}
})
test("expects addTen to add ten to any number", () => {
for (const number of numbers) {
expect(addTen(number)).toEqual(number + 10)
}
})
test("expects addTwo to add ten million to any number", () => {
for (const number of numbers) {
expect(addTenMillion(number)).toEqual(number + 10_000_000)
}
})
})
In the new three tests, we are setting just some arbitrary numbers to check that each of those functions will operate as expected.
Cool! We have managed to partially apply arguments to curried functions that returned a deterministic function that we could then apply another number to. What a mouthful.
At this point, I cannot stress how important the concept of partial application will be to functional programming. It may not look like much just yet, but hopefully I can convince you about how great it can be!
Stick with me, we are on the home stretch! Let's take what we have applied and see the power of things coming together.
Writing your own pipe and compose function
Now that we have been currying our add
function and partially applying values, we are at a point when testing is deterministic and our functions are so damn clear on what they do and very difficult to misuse (queue fact that there is no type safety).
Let's say we now want to write a function that can add twelve. What do we do now? Well, one thing we could do is follow our process like before of running const addTwelve = add(12)
, or we could begin to look at the power of functional programming and start applying the laws we learned at the beginning to create new, already-tested functions that can robustly give us confidence!
Given that we have an addTwo
and addTen
function, and - if math is still math - we know through our laws that addTwo(addTen(5))
would run 2 + 10 + 5
which gives us seventeen, but what happens if we don't want to write that junk each time?
In comes "pipe" and "compose".
Pipe and compose give us a way to take an array of functions and run them over a data type using the power of partial application. The only difference is that pipe operates from left-to-right while compose operates right-to-left.
This may make more sense once we write some code for pipe. Update our index.js file to now take this:
const add = x => y => x + y
// partially applied functions
const addTwo = add(2)
const addTen = add(10)
// using the underscores for readability
const addTenMillion = add(10_000_000)
// our pipe function
const pipe = (...fns) => data => fns.reduce((acc, fn) => fn(acc), data)
module.exports = {
add,
addTwo,
addTen,
addTenMillion,
pipe,
}
The important part is our pipe
function. It looks pretty crazy right now! Let's step through what it is doing:
- Declaring the variable
pipe
which is a function - When you call
pipe
, it takes any number of function arguments. The...fns
here using the operator...
to enable us to take an indefinite number of arguments. We could callpipe(addTwo)
,pipe(addTwo, addTen)
orpipe(addTwo, addTen, addTenMillion)
and all would be valid as it takes each argument and adds it to an arrayfn
. For those given examples, it would set the value offn
to[addTwo]
,[addTwo, addTen]
and[addTwo, addTen, addTenMillion]
respectively. Aspipe
is a curried function, it returns another function. - This function given back from
pipe
can then be called with argumentdata
. The data in this instance will be our base number value that we will pass through the pipe. - Finally, after completing our partial application, it will run through the array of functions
fns
and call the function on the accumulated value acc, starting with the value ofdata
.
That is a lot of information. But fear not, our use of this function below can show you this in action.
const data = 2
// calling the curried function straight away
pipe(addTwo)(data) // 4
pipe(
addTwo,
addTen
)(data) // 14
pipe(
addTwo,
addTen,
addTenMillion
)(data) // 10_000_014
// partially applying the function first
const addTwelve = pipe(
addTwo,
addTen
)
addTwelve(2) // 14
addTwelve(10) // 22
addTwelve(14) // 26
Whoa! Did you just see that? We are now able to take a number like 2 and pass it through a pipe that will apply all the functions we give it!
Let's step through the addTwelve
function. This will make sense of the steps we walked through above!
- First, we call
pipe(addTwo, addTen)
and assigned it toaddTwelve
. By passingaddTwo
andaddTen
, pipe will assign the parameterfns
to be[addTwo, addTen]
. - We calling
addTwelve(2)
, we are then assigning 2 todata
. - The reducer runs with the base value being 2. It then applies the functions from left to right and assigns
acc
to the response. This means that we runaddTwo(2)
which gives back 4. 4 is assigned toacc
and when then pass that value toaddTwelve(4)
to get us to 16.
While this example is trivial, it is pretty amazing what we can do. But here is the real kicker: because of those laws we spoke of at the beginning, we can pipe the result of pipe functions back into other pipe functions.
Say what?
const data = 2
// calling the curried function straight away
const addTwelve = pipe(
addTwo,
addTen
)
const addTwentyTwo = pipe(
addTen,
addTwelve
)
const addThirtySix = pipe(
addTwelve,
addTwentyTwo,
addTwo
)
addThirtySix(data) // 38
We can validate that this works by adding a test to index.test.js
:
// index.test.js - the rest is omitted for brevity
test("expects addThirtySix to add 36 to each number", () => {
for (const number of numbers) {
expect(addThirtySix(number)).toEqual(number + 36)
}
})
That. Is. Incredible. While we have been using simple arithmetic so far, try to imagine the possibilities of applying functions on a certain data type and begin making these incrediblly powerful pipelines!
So how do we build our own compose function? Remember how I said that pipe
is left-to-right while compose
is right-to-left? Well...
// our pipe function
const pipe = (...fns) => data => fns.reduce((acc, fn) => fn(acc), data)
// our compose function
const compose = (...fns) => data => fns.reduceRight((acc, fn) => fn(acc), data)
// functionally equivalent
const addTwelvePipe = pipe(
addTwo,
addTen
)
const addTwelveCompose = compose(
addTen,
addTwo
)
That's right! We simply use of Array's reduceRight prototype method. That's it!
We could pass the same arguments in the same order to both pipe
and compose
and get the same answer thanks to our four properties of functional programming that we covered in the beginning!
Demoing applications with strings
Are you sick of talking arithmetic yet? I sure am. I wanted to start showing you the power of the functions by creating small, deterministic functions and applying them in the real world.
Strings are a great place to do this, as we manipulate these all the time, however, you should note that the concepts can apply to other types as well (which is where you begin delving into the world of functional types like your monads, etc).
For now, let's add a few functions to our index.js
file. I'm not going to explain too much about these functions, but just know that we expect to take a string, run some form of manipulation and return a string.
// index.js - rest omitted for brevity
// str manipulations
const lowercase = str => str.toLowerCase()
const capitalize = str => str.charAt(0).toUpperCase() + str.slice(1)
const removeFirstTwoChars = str => str.slice(2)
const addPrefix = str => "pre-" + str
const strPipe = pipe(
lowercase,
removeFirstTwoChars,
capitalize,
addPrefix
)
const strCompose = compose(
lowercase,
removeFirstTwoChars,
capitalize,
addPrefix
)
console.log(strPipe("Hello, World!"))
console.log(strCompose("Hello, World!"))
This time, we're not going to write a test. Just run node index.js
and you will get the following output:
$ node index.js
pre-Llo, world!
e-hello, world!
Simply by running either left-to-right or right-to-left, we have ended up with vastly different answers!
While the example above may be trivial, I use composition all the time to do things like group similar Regex replacement functions to make them incredibly readable.
I used them this week to help with converting our three hundred Less files to Sass in our codebase!
// This is a snippet of the code to make things
// more readable!
/**
* Group mixin fns together. Order here is really important.
*/
const replaceMixins = pipe(
replaceMixinDeclaration,
replaceMixinUsage,
replaceMixinArgumentSemiColons
)
/**
* Replace variable prefixes @ -> $ and handle
* the imports from our Sass design system instead
* of less.
*/
const replaceVariables = pipe(
replaceVariablePrefixes,
replacePathToKaizenImports
)
/**
* Handle custom and inbuilt function conversion.
*/
const replaceFunctions = pipe(
replaceLessFns,
replaceCalcFn
)
/**
* Partial application of the pipe with the functions
* we wish to iterate through.
*/
const convertLessToSass = pipe(
replaceVariables,
replaceFunctions,
replaceMixins
)
// ... when applied in another file
for (const lessFile of lessFiles) {
const sassFile = convertLessToSass(lessFile)
fs.writeFileSync(pathToSasFile, sassFile, "utf-8")
}
Conclusion
We've covered the four core principles at the heart of functional programming, then followed the trail of understanding currying, partial application, and finally created our own simple examples of pipe
and compose
to see them in action!
This one was a big effort!
Functional programming is another tool on the tool belt to choose from. This was only scratching the surface, but it sets a great foundation for those of you who are interesting in seeing what it is all about.
The concepts we covered today go a long way into breaking down the paradigm shift required for functional programming and understanding them will be fundamental to conquering what comes next.
Let's finish by looking back at Lady Monadgreen’s curse again.
"Once you understand monads, you immediately become incapable of explaining them to anyone else"
Next time, we will take this curse head out and come out victorious!
Just as an aside, for anyone interested in how I generated the math syntax, check out my article on using Puppeteer to screenshot Katex generated code.
Resources and further reading
- Currying
- Set Theory
- Array.prototype.reduceRight
- Completed project
- 4 properties of functional programming
Image credit: Patrick Hendry
Originally posted on my blog. Follow me on Twitter for more hidden gems @dennisokeeffe92.
Posted on July 10, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 14, 2024
November 10, 2024