Have you ever found yourself doing something that looks similar to this pattern?
const userInfoFromStorage = localStorage.getItem('userInfo')
const userInfoParsed = JSON.parse(userInfoFromStorage)
const userInfoClearedCache = { ...userInfoParsed, cache: {} }
return userInfoClearedCache
Lots of use-once temporary variables. I used to do this too. I'd come up with creative ways to name those variables to sort of "self-document" what was happening. But it's generally a bad idea, here are some reasons why:
- The more variables you create, the more likely you are to have a typo.
- The operations performed on the data are surrounded by a lot of syntax noise.
- Variable names don't take the place of comments.
- Naming needless variables is an unnecessary mental load.
There are only two hard things in Computer Science: cache invalidation and naming things.
-- Phil Karlton
But what should we do instead? Clearly this isn't going to be easy code to work with:
return {
...(
JSON.parse(
localStorage.getItem(
'userInfo'
)
)
),
cache: {}
}
Yikes! 😲
While ugly, the code above does show that it is possible to express this transformation as a series of function calls with no variables. The output of one function is the input of the next function, and so on. We want to pipe values between functions. One solution is to write a function that allows us to make this abstraction. So how do we do this?
Here is one solution using reduce
:
const pipeline = ( initialValue, ...fns ) => (
fns.reduce(
( val, fn ) => fn( val ),
initialValue
)
)
As you can see, this function takes an initialValue
and uses reduce
to feed the output of one function into the input of the next one. If you are not sure why I use reduce here, part I of my reduce tutorial may be helpful. Now we can refactor the original code like so:
return pipeline(
// get user information stored as a string in local storage
localStorage.getItem('userInfo'),
// parse the string
JSON.parse,
// reset the cache to an empty obj
obj => ({ ...obj, cache: {} })
)
Isn't this much cleaner? It is easy to see how data flows from top to bottom.
There are two common challenges some developers may have with this style:
- Types. How do you type a
pipeline
function? - Logging (for debugging). How do you log data between the pipeline chain?
We will address these questions here:
The Typed Version
There is a way to express this function using a self-referencing type, but that is complex and warrants a separate article. For those who are curious, this is what such a type would more or less look like:
https://github.com/babakness/soultrain/blob/v0.0.43/src/type/_experiements.ts#L18
Alternatively, we can simply use TypeScript's function overloading to express how the type should evolve. For example:
function pipeline<A,B>( a: A, ab: (a: A) => B ): B
function pipeline<A,B,C>( a: A, ab: (a: A) => B, bc: (a: B) => C ): C
function pipeline<A,B,C,D>( a: A, ab: (a: A) => B, bc: (a: B) => C, cd: (c: C) => D):D
// and so on
This is essentially how pipeline
works in my library Soultrain.
Please note that this library is still experimental and there will be breaking changes. If you are kicking the tires on this within another experimental project, it is best to lock it down to a specific version.
Install
npm install soultrain
Soultrain
Soultrain is a compact functional library written in Typescript. It is inspired by Ramda and container-style programming based Algebraic Data Types.
Motivation
Ramda is a great tool, however, it relies on JS DOC comment specifications to provide type data. While this is good, Typescript provides superior static-typing. Thus this library aims to provide general purposes functional tools with much better static-typing.
Overview
This section will be completed later. From a high-level, the library provides smart curry
, pipe
, and pipeline
functions with good type support. It currently provides two Monads, a Maybe
and a Transduce
. The former works similar to other Maybe libraries, however, it provides…
The benefit of the typed version is getting the compiler to double-check the code. Here is an example that the JavaScript version would not catch:
The compiler has noticed that roo
is not a property on the object returned by processor.process
Logging / Debugging
To log data flowing between the functions in the pipeline, one could simply use a log function that both logs and returns the data it receives. With types it could be written as such:
const log = <A>(data: A): A => (console.log(data),data)
Or, to log with a message one could define log like this:
const noteLog = (note: unknown) => (
<A>(data: A): A => ( console.log(note, data), data )
)
Which can be used thus:
return pipeline(
// get user information stored as a string in local storage
localStorage.getItem('userInfo'),
// parse the string
JSON.parse,
// log the data,
noteLog('data before cache removal'),
// reset the cache to an empty obj
obj => ({ ...obj, cache: {} })
)
As an exercise for the reader, one could also create a function that conditionally triggers the debugger.
Functional Programming
The pipeline
function, the eager sibling of pipe
and compose
, will invite you to write more functions. It will also demonstrate to you how powerful currying can be; and even inform you on the order your function parameters should be.
To illustrate this, notice the arrow function in our pipeline example. Isn't obj
a single-use variable? All we're using it for is as a placeholder for our data... just a few character to the right!
Turns out that by making sure our source data is the last parameter and by using curried functions, we can simplify our code even further. Below I've written how this could be expressed in vanilla JS--notice that we want to override keys for the incoming object. It will be the second parameter--thus moving from left to right, overriding the second object, obj2
, with the object in the first parameter, obj1
.
So instead of placing obj2
to the right of the object spread, we reverse the order and place it on the left:
const mergeLeft = curry(
// spread the second parameter first
( obj1, obj2 ) => ({ ...obj2, ...obj1 })
)
If you're unfamiliar with curry
, it basically modifies our function so that it can be used as if it were also written this way:
const mergeLeft = obj1 => obj2 => ({ ...obj2, ...obj1 })
Now our solution becomes:
return pipeline(
// get user information stored as a string in local storage
localStorage.getItem('userInfo'),
// parse the string
JSON.parse,
// log the data,
noteLog('data before cache removal'),
// reset the cache to an empty obj
mergeLeft({ cache: {} })
)
Piping encourages us to write small, re-usable functions that perform specific tasks and that take the source data as the last parameter. This is similar to commands on the UNIX or Linux terminal. It's a functional programming concept that has been around for a long time. And like a great classic, it is a principle that proves itself better with time.
ES-Next
Piping data between functions is a great alternative to creating single-use variables which are only used to pass data from one function to another. Many programming languages recognize this and have an operator to perform this task. For JavaScript, there is the proposed pipeline operator
tc39 / proposal-pipeline-operator
A proposal for adding a useful pipe operator to JavaScript.
Pipe Operator (|>
) for JavaScript
- Stage: 2
- Champions: J. S. Choi, James DiGioia, Ron Buckton, Tab Atkins-Bittner, [list incomplete]
- Former champions: Daniel Ehrenberg
- Specification
- Contributing guidelines
- Proposal history
- Babel plugin: Implemented in v7.15. See Babel documentation.
(This document uses %
as the placeholder token for the topic reference
This will almost certainly not be the final choice
see the token bikeshedding discussion for details.)
Why a pipe operator
In the State of JS 2020 survey, the fourth top answer to “What do you feel is currently missing from JavaScript?” was a pipe operator. Why?
When we perform consecutive operations (e.g., function calls) on a value in JavaScript, there are currently two fundamental styles:
- passing the value as an argument to the operation (nesting the operations if there are multiple operations),
- or calling the function as a method on the…
Which would allow us to rewrite our original example like this:
return (
// get user information stored as a string in local storage
localStorage.getItem('userInfo'),
// parse the string
|> JSON.parse
// reset the cache to an empty obj
|> obj => ({ ...obj, cache: {} })
)
Until then, we can benefit from using the pipeline
function today to avoid unnecessary variable declarations.
Top comments (0)