This article is based on an internal presentation done by Mike Solomon, who will be further referred to as "my boss."
As a team, we decided to integrate functional programming practices into the codebase for our web application. More specifically, we're using fp-ts
, a library for typed functional programming in TypeScript.
This article explains why we chose fp-ts
and walks through a practical example using the pipe
function.
In this article:
- Why we're going functional
- Working with our existing React codebase
- Putting it into practice with
pipe
- More with
fp-ts
Why we're going functional
Because my boss likes Haskell 🤷♀️
I'm joking (mostly). My boss does have an affinity for functional programming and he's more comfortable in this type of workflow. But even if the learning curve is steep for those of us who didn't know what monads are, we've realized something. Adopting functional programming practices has improved our web application.
Here are some of the reasons:
Productivity
-
Descriptive errors - When we see logs in the console, it's rarely
Uncaught TypeError: Cannot Read Property 'name' of undefined
orObject doesn't support property or method 'getPosts'
. This helps for more efficient debugging. - Less code - Functional programming takes care of many patterns that would otherwise result in boilerplate code.
- Limited options - With functional programming, you can only do things a certain number of ways.
- Refactoring - With strong type safety, you refactor "against" the compiler. This means the red squiggles in your IDE guide the refactoring process and proposes helpful suggestions.
Correctness
- Type safety - When you use a typed variable, you're defining a constraint on all possible values. This helps ensure that the inputs and outputs of our code work as expected.
- Error routing - With functional programming, errors become first-class citizens and are propagated to error handlers based on rules.
-
Linear ordering - No more jumping between
if
thiselse
that or getting stuck in a deep-nested JavaScripttry
/catch
block.
Why we chose the fp-ts
library
In theory, we could've switched out fp-ts
for another functional programming library for TypeScript like Purify. Both libraries have similar syntax for common functional patterns like the Either
class and the chain
function. However, fp-ts
has some additional classes that we use regularly like Reader
and Semigroup
.
If there were terms in that last paragraph that you didn't understand, don't worry! We'll cover those in a future post.
Working with our existing React codebase
Fortunately for us, the codebase we're working with is still fairly new. The repository was created a little over one month ago. The initial setup was done by two developers (myself included) with no functional programming experience. But, turns out, we were already applying functional programming principles to our React application.
Some examples:
- Hooks as a functional way to manage state dependencies.
-
Function components instead of
class
components. - Arrow function expressions, which, when used without brackets, enforces a single flow of information.
But taking that next step into the functional programming world required us to restructure the way we think about and read code. To make it more tangible, the rest of this article will focus on one specific function from the fp-ts
library: pipe
.
Putting it into practice with pipe
The concept of piping goes well beyond the fp-ts
library. According to The Linux Information Project, piping is defined as:
A form of redirection that is used to send the output of one program to another program for further processing.
Sounds intense and a bit abstract. Let's break it down.
Overall, a pipe is one big function of functions. It takes an initial value and then passes that as the argument(s) for the first internal function to use. Then it takes the result from that function and passes it to another internal function. And so on, potentially forever 🤪
Maybe it's better to explain with code.
Here's an example of piping written in vanilla JavaScript:
const examplePipe = (a, b, c) => c(b(a));
This examplePipe
function takes in three parameters (a
, b
, and c
). For examplePipe
to work as expected, a
should be a value that can be consumed by b
. Then b
should be a function that takes a
as an argument. Finally, c
should be another function that takes the result of b
as an argument.
Let's put in some arguments:
examplePipe(1, (x) => x+1, (x) => x+5)
First, it takes an independent value: 1
.
Then, 1
is passed to the next function: (x) => x+1
. So because x
is equal to 1
, the result is 2
.
Finally, this result (2
) is passed to the last function: (x) => x+5
. Because x
is now equal to 2
, the examplePipe
will return 7
.
And there you have it, our first pipe 🎉
This was a generic example of piping. Next, we'll go step-by-step to see how this would work in a web application. Throughout, we'll use the pipe
function that's available through the fp-ts
library.
Defining the initial value in a pipe
The most minimal pipe
we can write is a pipe
with a single object, like pipe(1)
. Here, the first value (1
) isn't consumed by any functions in the pipe
. This means that the result of pipe(1)
is equal to 1
.
As soon as a pipe
grows to two values, it then enforces a contract - the second element of the pipe
must be a function that can consume the first value. This first value can be anything: A number, a string, a class, a function, or even void
.
This is common practice in functional programming. Instead of defining variables along the way, everything we need is defined at the start. "Priming the pipe" so to speak.
Let's start creating an example. We're going to define an exampleFunction
that doesn't have any parameters and returns a pipe
. To start, pipe
contains an object with three values: projects
(independent getProjects
function), a users
array, and a configuration
object.
It should look like this:
const getProjects = () => ([]);
const exampleFunction = () => pipe(
{
projects: getProjects(),
users: [5],
configuration: {}
}
);
This example assumes we have
fp-ts
installed andpipe
imported.
Another nuance of pipe
is the order (or lack of order) that we define our initial values. To show how this works, let's look at a real-world example.
In our web application, we often define our hooks within this first part of the pipe
function. Alternatively, you could use const
to define variables like so:
const useColorMode = useColorMode()
const useDisclosure = useDisclosure()
In this structure, useDisclosure
will always be executed after useColorMode
. This is because JavaScript code executes in order.
But with an object, there are no guarantees about the order of execution. JavaScript doesn't indicate which values in an object are created in memory first. This is true for any object, but it's especially useful in our pipe
function.
Defining variables within the first object of pipe
signals to anyone maintaining the code that the order of these variables is insignificant. This allows us to refactor with more confidence.
What's also nice about putting these values first is that it distinguishes what is independent in your function. So no matter what, you know that these values don't have any dependencies or rely on anything else. This can help with debugging and code readability.
First function in the pipe
The next part of the pipe
is our first function. In this function, we can pass the values defined in the first object as an argument.
We do this in the following example with the valuesFromObjectAbove
parameter:
const getProjects = () => ([]);
const exampleFunction = () => pipe(
{
projects: getProjects(),
users: [5],
configuration: {}
},
(valuesFromObjectAbove) => ({
// Coming soon!
})
);
Here, valuesFromObjectAbove
represents projects
, users
, and configuration
.
We can then use valuesFromObjectAbove
to create new values. In this example, we're creating arrays of adminProjects
and notAdminProjects
using the projects
value we defined in the first object:
const getProjects = () => ([]);
const exampleFunction = () => pipe(
{
projects: getProjects(),
users: [5],
configuration: {}
},
(valuesFromObjectAbove) => ({
adminProjects: valuesFromObjectAbove.projects.filter(a => a.admin === true),
notAdminProjects: valuesFromObjectAbove.projects.filter(a => a.admin === false)
})
);
Now, we can see this grouping of independent values first, dependent ones second. Reading the code, we can deduce that adminProjects
and notAdminProjects
, by definition, depend on a value that was created earlier. This can help with debugging. For instance, if you insert a console.log()
statement after the first object, you know that your log will only contain the independent values in the function.
Another round of functions
There are a few options available for what values are passed to our second function.
One option is to use a spread operator:
const getProjects = () => ([]);
const exampleFunction = () => pipe(
{
projects: getProjects(),
users: [5],
configuration: {}
},
(valuesFromObjectAbove) => ({
...valuesFromObjectAbove, // Look here!
adminProjects: valuesFromObjectAbove.projects.filter(a => a.admin === true),
notAdminProjects: valuesFromObjectAbove.projects.filter(a => a.admin === false)
}),
(valuesFromFunctionAbove) => ({
...
})
);
By using the spread operator, we're saying that we want to pass down everything. This means that valuesFromFunctionAbove
contains all of the values from the initial object (projects
, users
, configuration
). And it also contains the values from the first function (adminProjects
, notAdminProjects
). Bonus: It's all type safe!
But let's say we delete the spread operator:
const getProjects = () => ([]);
const exampleFunction = () => pipe(
{
projects: getProjects(),
users: [5],
configuration: {}
},
(valuesFromObjectAbove) => ({
// No spread operator
adminProjects: valuesFromObjectAbove.projects.filter(a => a.admin === true),
notAdminProjects: valuesFromObjectAbove.projects.filter(a => a.admin === false)
}),
(valuesFromFunctionAbove) => ({
...
})
);
Now, the second function only has access to adminProjects
and notAdminProjects
.
That is the power of pipe
. We always know what's ready to use 💥
If organized appropriately, pipe
can contain everything that we would need to create our React component. So those ...
in the last two examples? That's where we could put in our JSX.
More with fp-ts
This article only scratched the surface of what the fp-ts
library can bring to a web application. On our team, there are many more functions and patterns that we use (Either
, chain
, isLeft
, isRight
, Reader
). If you'd be interested in learning about these, tweet at us or leave a comment and let us know!
In the meantime, check out the fp-ts
documentation.
Top comments (1)
Hello Carolyn,
that is fairly didactical article, I like how you introduce and develop the subject. There is indeed a lot of functional patterns we can already put in use in our code without needing a pure functional language. It is also quite exciting to see companies that are using fp-ts in earnest. Two years ago, I wish there would have been more example code of how to put all this to good usage. They came a long way since then in terms of docs and examples.
I could not however find the reasoning behind those assertions:
use
). The first time they run and the second time they run with the same argument they do not do the same thing, by design. That design is also why there are the rule for hooks to follow. I confess I haven't used hooks so I maybe wrong. But a year or so, that was the conclusion I reached. That may have changed.The bottom line is that functional programming is not about the syntax, i.e. it is not just about using functions (who doesn't?). The essential pattern is expressing computations as the composition of pure functions. Hooks, in that matter, are a bit confusing because they compose (when applied in order, and within a React context) but they do not compose like pure functions do. That's better than not composing, but still, I would not call using hooks applying functional programming principles.
But that does not really matter though. The more important point is that I am going to check Meeshkan, PBT being something I have a strong interest in :-) Automatic testing for any app -> that is exactly what I call a problem worth solving.