Chris approached me on Friday and indicated he wrote a small utility for dealing with shortcuts and wondered if it made sense to use functional programming techniques to improve it.
Without seeing the code, I clarified that functional programming is nothing more than primarily using functions (with a few other techniques) to generate your desired value/data structure.
What I think Chris meant was he wanted to use some more declarative functional techniques to help specify what the program should do, and not necessarily how it should be done (e.g put this result into this variable, use a set of control structures to decide what to do next, etc). It's a very different model of programming, and while unfamiliar to some, can make programs more readable, more concise, and less prone to bugs.
The goal of is article is to lead the reader from a small imperative implementation to more a declarative one through a series of small, focused derivative changes.
There is an outstanding PR for this work, but some of the conversation about how to approach this happened outside Github. I've also added some addtional comments and analysis in here that I thought would be instructive.
The derivations below are mostly for illustrative purposes, but I do like this style, will continue to evangelize it, and would endorse in subsquent PRs (and our code) if used effectively.
That being said, while I find the evolutions highly readable, more easily testable, more concise and less error prone, for those unfamiliar some of the ideas/changes might make them uncomfortable and they should be in a balanced way.
Here is the original implementation:
/**
* Given the current array of keyboard keys, update all `cmd`,
* `shift`, and `enter` keys to match `⌘cmd`, `⇧shift`, and `enter↵`.
*/
export const addAsciiToKeys = (keys: string[]) =>
keys.map((element) => {
let key = element.toLowerCase()
if (key === 'enter') {
key.concat('↵')
} else if (key === 'shift') {
key = '⇧'.concat(key)
} else if (key === 'cmd') {
key = '⌘'.concat(key)
}
return key
})
Looks like this code takes in a list of keys, and in some special circumstances, adds some modifiers to the key, either before or after it. In most cases the code acts as a passthrough for original value.
On first look, this code actually looks pretty good! It's typed, uses a map
to apply a function to all the passed-in elements, and from the outside is immutable (i.e doesnt change original keys, instead passing a new set of values out).
There are a few things I would certainly ask for in a production environment, namely:
- Lift out inline the lamda function so we can more easily test that functionality.
- Give function a better name
But we'll also push the envelope a little bit on each successive pass, hopefully illustrating a few techniques that can make much more declarative.
import map from 'lodash/fp/map'
/** Given a key, update the `cmd`, `shift`, or `enter` keys to match `⌘cmd`, `⇧shift`, or `enter↵`. */
const mutateKey = (element: string) => {
let key = element.toLowerCase()
if (key === 'enter') {
key += '↵'
} else if (key === 'shift') {
key = '⇧'.concat(key)
} else if (key === 'cmd') {
key = '⌘'.concat(key)
}
return key
}
/** Given an array, return the array with updated keys. */
export const addAsciiToKeys = map(mutateKey)
There are some nice things in this first pass:
- Extracted out lamda into a named function
- Imported Lodash's auto-curried, data-last implementation of
map
to declaratively define the main function.
What's particularly interesting with this approach is that now describes a mapping between the inputs to their outputs. This is subtly (but importantly) different than:
export const addAsciiToKeys = (keys: string[]) => keys.map(mutateKey)
which contains some executable code to define its mapping. In Chris's, this is just a description of what should happen and and not a definition of how it should happen.
Since we don't need to define the parameter (called point-free) in the more declarative version, the lodash implementation is also shorter and more concise!
Here was some feedback to consider for a next pass:
- Extracting the lamda functionw was good, but the name isn't great as it suggests we are mutating the original value underneath is (it isn't). Can we come up with a function that suggests we are going from something to something else?
- Notice the top function is still written in the imperative manner, namely a temporary variable is assigned and potentially reassigned through an if/else control structure. There's nothing inherently bad in this implementation, but variable assignments (and reassignments) are definitely a surface on which bugs can be introduced. Can we write this in a better way, perhaps using cond from lodash which was learned earlier?
import map from 'lodash/fp/map'
import cond from 'lodash/cond'
import otherwise from 'lodash/stubTrue'
import constant from 'lodash/constant'
import identity from 'lodash/identity'
const isEnterKey = (key: string) => key.toLowerCase() === 'enter'
const isShiftKey = (key: string) => key.toLowerCase() === 'shift'
const isCMDKey = (key: string) => key.toLowerCase() === 'cmd'
/** Given a key, update the `cmd`, `shift`, or `enter` keys to match `⌘cmd`, `⇧shift`, or `enter↵`. */
const addAsciiValueToKey = cond([
[isEnterKey, constant('enter↵')],
[isShiftKey, constant('⇧shift')],
[isCMDKey, constant('⌘cmd')],
[otherwise, identity((key: string) => key)],
])
/** Given an array, return the array with updated keys. */
export const addAsciiToKeys = map(addAsciiValueToKey)
The second pass introduces cond
from Lodash. Cond
provides several advantages:
- Acts as a more powerful switch-like statement in that we can use function predicates to decide which value to return.
- Treats very common if/else structures as a linear truth table. Very easy to read (once youre used to them) and highly declarative
- Removes need for intermediate variables, a entry point for bugs
For cond
to work, Chris provides 3 intermediate function predicates, all of which have similar form:
const isEnterKey = (key: string) => key.toLowerCase() === 'enter'
const isShiftKey = (key: string) => key.toLowerCase() === 'shift'
const isCMDKey = (key: string) => key.toLowerCase() === 'cmd'
Not that we're optimizing for total line count, but even with the introduction three new function definitions, the codebase reduces from 12 lines down to 10 (not counting imports or comments), a 16% improvement.
Chris also introduced a few other low-level Lodash utils to help build out the cond
table:
constant
- Returns a function that will always return the same value. Seems like an odd utility but has lots of practical usesstubTrue
(aliased tootherwise
) is a function that always returnstrue
. Again, seems like a uselss utility but has lots of utility. Forcond
, it ensures we have a final case that will return a value. In this case, its acting as anelse
clause.identity
is a function that given a value returns the same value. Yet another odd utility, but recall that in original problem in the majority of cases we simply wanted to pass through the value we got.identity
to the rescue.
- Note - Chris is using
identity
slightly wrong here, but the idea of its use here is sound.
Chris also changed the function name from mutateKey
to addAsciiValueToKey
which I think is an improvement, but maybe we can do better.
Like the function addAsciiToKeys
, notice our new implementation of addAsciiValueToKey
is point-free and fully declarative, and simply describes a mapping of predicate values to constant values. The code is specifying what should happen in a highly concise manner.
That being said, we can do better here. First, there is a mistake with the use of the identity
function. Second, we've distributed the lower casing of the keys to each individual function, meaning:
- There is duplicated code in our above implementation
- Each of those function is imperative (again, nothing inherently bad here, but in the interest of the exercise we can do better)
- More importantly, if we needed to add more special cases, we've have to mimic the
toLowerCase
code yet again in the next functions.
At the time of this writing (November 23, 2021), the PR currently has the following:
import map from 'lodash/fp/map'
import cond from 'lodash/cond'
import otherwise from 'lodash/stubTrue'
import constant from 'lodash/constant'
import identity from 'lodash/fp/identity'
import isEqual from 'lodash//fp/isEqual'
import toLower from 'lodash/fp/toLower'
export const isEnterKey = (key: string) => isEqual(toLower(key), 'enter')
export const isShiftKey = (key: string) => isEqual(toLower(key), 'shift')
export const isCMDKey = (key: string) => isEqual(toLower(key), 'cmd')
/** Given a key, update the `cmd`, `shift`, or `enter` keys to match `⌘cmd`, `⇧shift`, or `enter↵`. */
export const addAsciiValueToKey = cond([
[isEnterKey, constant('enter↵')],
[isShiftKey, constant('⇧shift')],
[isCMDKey, constant('⌘cmd')],
[otherwise, identity((key: string) => key)],
])
/** Given an array, return the array with updated keys. */
export const addAsciiToKeys = map(addAsciiValueToKey)
The notable differences from this code vs the last pass is:
- The introduction of
isEqual
from lodash (in place of===
) - The introduction of
toLower
from lodash (in place ofstr.toLowerCase()
)
to implement the three functions isEnterKey
, isShiftKey
, and is isCMDKey
.
There are some potential advantages to using these two functions over their vanillar Javacsript counterparts:
- The functions can potentially be more declarative.
- Functions provide a universal interface that allows for composition. The method
toLowerCase
that hangs off of string objects is not nearly as composable as the functional equivalent.
export const isEnterKey = (key: string) => isEqual(toLower(key), 'enter')
export const isShiftKey = (key: string) => isEqual(toLower(key), 'shift')
export const isCMDKey = (key: string) => isEqual(toLower(key), 'cmd')
Notice if you squint your eyes that we now have function calls within function calls, something that is just asking to be more declaratively composed using something like flow
or compose
However, this code is still by definition imperative.
Also note that we hadn't addressed the concern that toLower
is still being called 3 times in this codebase. How can we get to just one?
We also haven't address the issue with identity
being used correctly (frankly I'm surprised the above works)
Finally, there are a few lodash imports where we're using the fp/*
version of the method where we really don't need to. Not a big deal, but something we can choose to clean up.
Now that we've gone through several derivations and iteratively improved our definitions, let's see if we can button up some of the final concerns:
identity
not being used correctly.toLower
is being distributed to all function checks. Can we centralize?
Since the identity
function simply takes in any value and returns it, it means we can simplify the cond
expression to the below since cond
expects a function with one parameter:
export const addAsciiValueToKey = cond([
[isEnterKey, constant('enter↵')],
[isShiftKey, constant('⇧shift')],
[isCMDKey, constant('⌘cmd')],
[otherwise, identity],
])
Notice this not only makes the definition more concise, but also more declarative.
We're going to do this part in steps.
Let's start by assuming we will be able to centralize the toLower
call outside of each definition. If that's the case, then the three function calls are simplified to this:
import isEqual from 'lodash/isEqual'
export const isEnterKey = (key: string) => isEqual(key, 'enter')
export const isShiftKey = (key: string) => isEqual(key, 'shift')
export const isCMDKey = (key: string) => isEqual(key, 'cmd')
Already simpler, but notice all the function definitions all have the same form in that they are configured with the string to compare to and just need data. What if we used the curried, data-last versions of isEqual
from lodaash/fp
?
import isEqual from 'lodash/fp/isEqual'
export const isEnterKey = isEqual('enter')
export const isShiftKey = isEqual('shift')
export const isCMDKey = isEqual('cmd')
Whoa, big changes! The code is now point-free (no explicit function parameter lists), much more concise and declarative, and yet still work exactly the same way since isEqual
is now generating functions for us with one argument (which is exactly what cond
needs).
Given how simple these are, I'd even hesitate to define these as their own functions, but we can get to that later.
What about the fact that we removed toLower
? We still need to make our isEqual
comparisons case-insensitive. Let's address that next by introducing flow
flow
is a higher order function that accepts a list of functions and composes them directly as a data pipeline. We can leverage flow
to connect toLower
to our cond
backed function like so:
import flow from 'lodash/flow'
import toLower from 'lodash/toLower'
// Original function, but I changed the name
export const lowerCaseKeyWithAscii = cond([
[isEnterKey, constant('enter↵')],
[isShiftKey, constant('⇧shift')],
[isCMDKey, constant('⌘cmd')],
[otherwise, identity],
])
// New function with old name
export const addAsciiValueToKey = flow(toLower, lowerCaseKeyWithAscii)
// Stays the same!
export const addAsciiToKeys = map(addAsciiValueToKey)
In the above code, I renamed the original cond
function to lowerCaseKeyWithAscii
to signify that it only accepts lowercase values.
Then, I used the old function name addAsciiValueToKey
and defined as a declarative composition of toLower
and lowerCaseKeyWithAscii
. The final last export remains the same and still works. Altogether:
import flow from 'lodash/flow'
import toLower from 'lodash/toLower'
import isEqual from 'lodash/fp/isEqual'
import map from 'lodash/fp/map'
import constant from 'lodash/constant'
import otherwise from 'lodash/stubTrue'
import identity from 'lodash/identity'
export const isEnterKey = isEqual('enter')
export const isShiftKey = isEqual('shift')
export const isCMDKey = isEqual('cmd')
export const lowerCaseKeyWithAscii = cond([
[isEnterKey, constant('enter↵')],
[isShiftKey, constant('⇧shift')],
[isCMDKey, constant('⌘cmd')],
[otherwise, identity],
])
export const addAsciiValueToKey = flow(toLower, lowerCaseKeyWithAscii)
export const addAsciiToKeys = map(addAsciiValueToKey)
This is a completely decalrative approach towards the implementation of original goal, and largely uses primitives from lodash to execute. This version is one line longer (not counting imports) than our 10 line effort from before, but still one line shorter the original.
In fact, based on my earlier comment, in this particular case, it may not even make sense to create the 3 function predicates as named functions, instead inlining them like our constant
definitions, like so:
import flow from 'lodash/flow'
import toLower from 'lodash/toLower'
import isEqual from 'lodash/fp/isEqual'
import map from 'lodash/fp/map'
import constant from 'lodash/constant'
import otherwise from 'lodash/stubTrue'
import identity from 'lodash/identity'
export const lowerCaseKeyWithAscii = cond([
[isEqual('enter'), constant('enter↵')],
[isEqual('shift'), constant('⇧shift')],
[isEqual('cmd'), constant('⌘cmd')],
[otherwise, identity],
])
export const addAsciiValueToKey = flow(toLower, lowerCaseKeyWithAscii)
export const addAsciiToKeys = map(addAsciiValueToKey)
This is even shorter at 8 lines! (and 33% smaller than original code) and has the nice advantage that we can directly compare the predicates with their output value really easily.
This document took us through a set of iterative evolutionary steps to turn a short imperative code snippet into a more declarative description of the exact same task.
By leveraging about a half dozen primitives from lodash, we were be able to describe the transformation of a string into an augmented one that included modifier text in certain scenarios without writing a single executable line of code, instead building up a set of functions that concisely described the transformation, and in a third less code.
Declarative programming takes practice to both read and write, but with practice comes an increaed confidence in delivering code that is easier to read, easier to reason about, easier to test, easier to extend, and that devoid of bugs (and prevents bugs from creeping in).
Declarative programming can be combined with any and all other techniques you know, and while good for many situations, may not be applicable in all so use your best judgement!