Disclaimer: I am a huge fan of the
fp-ts
project and think it is one of the greatest, if not the greatest,typescript
libraries on the planet. This article is in no way meant to disparagefp-ts
, but rather to describe some (what I believe to be) legitimate reasons not to use it and provide context around my thinking.
Almost every article I've written on dev.to over the past year has touched functional programming in some form or another. They've ranged over subjects like:
- Modeling asynchronous transactions with types
- Composing readers
- Piloting Puppeteer with PureScript
- Freeing Free Monads with Free ADTs
- Dead Simple GraphQL with Typeclasses and functional dependenceis
- ...
In this article, I'd like to discuss a great reason not to use functional programming. It comes from a short-lived period (~30m) where fp-ts
was in, then out, of our stack.
In the first part of the article, I'll show how I used fp-ts
to do pattern matching on an internal data format. In the second, I'll discuss why this didn't work and what the alternative solution was. And in the third part, I'll get a bit meta and discuss the why and when of functional programming.
Hello fp-ts
At Meeshkan, we use AI to generate Selenium and Puppeteer tests of webapps based on real user behavior. One of our clients recently asked us to export these scripts so that they could be run in their private cloud.
We decided to embed the exporter in our webapp, as the app has access to all the data needed to generate a script and it's way faster for us to ship a client-side feature than a backend feature.
Because each command in the script could be one of several actions, my brain automatically went to variants. In languages like Haskell, PureScript, OCaml and Elm, variants work like this:
data Comamnd =
NavigateToPage {url :: String }
| ClickOnElement {xpath :: String }
| EnterInput {xpath :: String, value ::String }
In TypeScript, you can sort of do this.
type Command = {url :: String } | {xpath :: String } | {xpath :: String, value ::String }
This is called an untagged union, and the issue with it is that it is a one-way street. While you can get data into the untagged union, getting it out is a pain because there is no clean way to discriminate between values. The closest you can get is the is
operator, which kinda works but requires you specifying what the definition of is
is, which is (😉) prone to error. It also does not work for data structures that have identical fields or data-types that are subsets of another data type.
fp-ts
, on the other hand, allows you to operate on tagged unions. The tagged union I wrote was called Command
, and I wrote functions to move objects into the tagged union with these signatures.
const open: (o: Open) => ScriptCommand;
const setViewportSize: (o: SetViewportSize) => ScriptCommand;
const click: (o: Click) => ScriptCommand;
const type: (o: Type) => ScriptCommand;
const dragndrop: (o: Dragndrop) => ScriptCommand;
And then, to get out of the tagged union, you need a generic function from all possible variants to the output type. It's type is (was...can types die?) this:
const transformCommand = <T>(c: {
open: (o: Open) => T;
setViewportSize: (s: SetViewportSize) => T;
click: (c: Click) => T;
type: (t: Type) => T;
dragndrop: (d: Dragndrop) => T;
}) => (command: ScriptCommand): T
If you give it a dictionary of converters to type T
and a command, it will output something of type T
.
The implementation using fp-ts
-'s fold
is a one-liner:
const transformCommand = <T>(c: {
open: (o: Open) => T;
setViewportSize: (s: SetViewportSize) => T;
click: (c: Click) => T;
type: (t: Type) => T;
dragndrop: (d: Dragndrop) => T;
}) => (command: ScriptCommand): T =>
fold(c.open, fold(c.setViewportSize, fold(c.click, fold(c.type, c.dragndrop)))
)(command);
Then when I wanted to convert our command to a string of Puppeteer commands, I gave it a dictionary of transformers to Puppeteer and the fp-ts
took care of the rest thanks to fold
.
I wrote the code this way because it was comfortable for me, but at the same time, I knew that several of our team members had little experience with fp-ts
and/or had negative experiences of not understanding code written using fp-ts
. It's not hard to see why: in the example above, if you don't know what fold
is doing (and in general if you don't know what folding is or pattern matching is from functional languages), it is really hard to grok how transformCommand
works.
Goodbye fp-ts
After a talk with our lead designer / lead web developer / product manager / COO Makenna, she expressed that she was uncomfortable with fp-ts
and that the same outcome could be accomplished by just converting the data from 8base to a string without going through an intermediary format. She was absolutely right: in terms of our business logic, the intermediary format has no benefit as the data would always be coming from 8base. The code became:
const eightBaseToX = (formatter: {
open: (o: Open) => string;
setViewportSize: (o: SetViewportSize) => string;
click: (o: Click) => string;
type: (o: Type) => string;
dragndrop: (o: Dragndrop) => string;
}) => (
script: SeleniumScript,
options: ScriptToPptrOptions
): string | undefined;
You have to squint to see the difference. It is exactly the same as before except it is operating on the data from 8base, which is a bunch of nullable fields organized more or less as a sequence of executable selenium commands, to a Puppeteer script. The only difference is that it cuts out the intermediary format, aka our tagged union ScriptCommand
. But as we'd only ever be getting the data from 8base, there's no need for an intermediary format.
The refactor took 10 minutes and is ~50 less lines of code, doesn't introduce a new dependency, and still uses a functional style, all in a way that my team can understand and maintain. Why wouldn't anyone want this?
At the end of the day, fp-ts
only would have made sense if Makenna and others were comfortable with it enough to share its maintenance burden. Otherwise, by all objective standards, I had written worse code with fp-ts
: it was longer and had an extra dependency. And this is not to say that Makenna is not comfortable with functional programming - she uses a purely functional language (jsx
), hates classes in react, lauds hooks, and has to put up with me talking about functional programming five times a day. So really, what we're talking about here is someone who is excellent at functional programming (Makenna) asking someone who is obsessive about it (me) to tone it down a notch.
What that got me thinking about, then, is when does one use functional programming? What made me the sort of developer that instinctively reaches for solutions like PureScript and fp-ts
? That introspection led me to create four-ish paths to functional programming. I'll present them below, and I hope these distinctions are useful to you, whether you're a seasoned veteran or just starting out with FP.
When to use functional programming
There are four reasons I've seen people come to functional programming. I think the first two generally don't work out whereas the last two do. That doesn't mean that the first two are invalid, but rather that they do not generally lead to a sustainable outcome. They are:
- Appetite to learn and apply new things ❌
- Need to solve a problem ❌
- Need to solve many recurring & interrelated subtle problems that only emerge over years of practice ✅
- Trusting someone from category #3, ie because they were your teacher or mentor ✅
1. Appetite to learn and apply new things ❌
This is the most dangerous category and the one I admittedly fall into. Someone with an appetite to learn new things often winds up coding a solution to a problem that doesn't exist. This eats up valuable business time and leads to situations where people convince themselves a problem actually does exist, namely that their solution is not implemented, and then solve it with an implementation using their solution.
The other danger with this approach is that it is usually based on an emotion of excitement or intrigue. When that excitement or intrigue fades, what will happen? Who on the team will maintain the code?
While I think an ability to learn and apply new things is a valuable skill, an appetite for new things can be destructive, and it is IMO never a good reason to adopt functional programming (or any) techniques in your stack.
2. Need to solve a problem ❌
Reaching for functional programming when you need to solve a problem is almost never necessary unless it is already your go-to method. Meaning that, as we saw in my fp-ts
example, for every solution that can be achieved with a functional pattern, the same solution can be achieved with a slightly-less-functional-but-still-functional-and-now-100%-more-maintainable-by-your-team pattern. So solving a problem with purely functional techniques only makes sense in a context where these techniques are go-to methods for a group of people working together or a one-person project.
Another important thing to note here is the global imbalance in functional vs imperative programming. Chances are that, unless your company already has a reputation as a purely-functional house, your team will hire coders that are mostly comfortable with imperative code. I know that for us at Meeshkan, hiring great talent from the functional programming community was a months-long process, whereas hiring for other roles garnered many more applications. A free monad doesn't mean squat if you run out of time/money because you couldn't hire a colleague that understands what a free monad is.
So functional programming is never the solution to a problem. It can be a solution because "that's the way I solve problems", but there is no coding problem that can be uniquely solved with with functional programming. Take any metric of business success: sustainability, profit, advocating human rights and social justice, whatever, and I guarantee you that not a single successful category will have a majority of companies that felt functional programming was a key to achieving that successful outcome. Unless, of course, the metric is "uses functional programming."
That doesn't mean that functional programming cannot be crucial for business success, but rather that if it is (which is yet TBD), it will be a slow and gradual process, much like the adoption of modern computing was a slow and gradual process towards more successful business outcomes (and, let's be honest, there is a strong argument that computing has left the world worse off than it was 100 years ago due to the increased pollution, poverty, sickness and war that is orchestrated by computers). In fact, many companies that originally used computers in the 1930s and 1940s stopped using them for the exact same reason that I typed yarn remove fp-ts
: they got in the way more than they helped. Jared Diamond makes a similar argument about agriculture in Guns, Germs and Steel. Many early agrarian societies went back to hunter-gatherer or hybrid societies before hitching ourselves to the plough for good and, similar to computers, agriculture has not necessarily been the greatest gift to our species (think nutrition, women's rights, overpopulation, etc.).
Ultimately, whether functional techniques become commonplace or not will have to do with our capacity, as a community, for collective abstraction. The same is true of computers: computers only work on a species-level because of our capacity for collective abstraction. When I'm waiting for the bus and looking at the number of minutes until it arrives on a digital display, whoever wrote the code that orchestrates the clock is relying on my believing that the clock, even though it has no cogs, mechanics or physical connection to a bus, accurately conveys information about a bus's arrival time. This is not at all a given - it has taken years for us, as a species, to take this shared understand more or less for granted. If I were born 150 years ago and time traveled to today's day-and-age after ~40 years of existence, there is no guarantee I would ever learn how to read, let alone how to trust, a digital display of bus times. I am only able to do that in 2021 because I live in a social context where that is a collective practice. I can observe people watching digital displays of bus times and acting on them, and I assimilate this knowledge into my own actions. The gradual adoption of functional techniques, if it happens, will happen because thousands of people work together to establish a similar context over many years.
3. Need to solve many recurring & interrelated subtle problems that only emerge over years of practice ✅
Although I'm one of the older people on my team, I was not around when the first hammer was invented, but my guess is that when it was, it was invented to solve many recurring & interrelated subtle problems that only emerge over years of practice. That doesn't mean that the invention itself was gradual: it could have been instantaneous and even accidental. But its acknowledgement as an invention and gradual adoption in a variety of contexts could have only happened if it solved several interrelated problems (hitting a nail, hitting a flint, hitting a tree, hitting a bison, hitting a shell...).
My path to functional programming had many false starts because I fell into categories #1 and #2, but when I actually started using it for real, it was for this reason. For example, our backend recently exploded because one of our vendors was 400
-ing and 500
-ing with timeouts & sundry, and we needed to come up with a queuing system for requests that could flip between parallel and sequential execution based on this vendor's status to bring our stack back online. We wound up being able to ship something in a matter of hours. The solution wound up using the tagless final technique on a StateT
monad over a functor that controlled parallelism (ie Fiber
in PureScript), allowing me to alternate between different executors on the fly depending on the number of 400
-s and 500
-s we were receiving. I would have never been able to write something this fast that is type-safe and tested with QuickCheck ~15 years ago, and I would never expect someone getting started to understand what a monad is (let alone tagless final), but now it feels natural. Because I work in a context with other folks for whom this feels natural, I can write this sort of code.
Functional programming wound up emerging as a way to act quickly, decisively, and in a future-proof manner in this many other situations. I think a lot of folks that write PureScript and Haskell feel the same way. Similarly, people I know that have been writing code for ~20 years that don't use functional programming also don't routinely run into situations where it would have been particularly helpful (otherwise they'd be pretty miserable!). In my case, though, FP has been a reliable pattern time-and-time again, and I'm fortunate enough to work in a place where I can do this, in large part because I started my own company precisely so I could work in a place where I can do this.
4. Trusting someone from category #3, ie because they were your teacher or mentor ✅
Most folks that learn programming in 2021 learn an untyped, imperative form of programming first. However, there is a small but growing cohort of learners that learn from a functional programmer. Teaching someone functional techniques before imperative techniques is playing with fire because it gives them a skill set that is misaligned with the job market. But if you can pull it off, I find that the person is usually better equipped to build great things with code.
One thing that is important to note is that learning imperative programming is not at all a precursor to learning the concepts behind functional programming. Combinators, the lambda calculus, first-order predicate logic and category theory all existed before imperative programming became mainstream.
Another observation I've made is that, while I've seen many people adopt functional programming techniques that stay with them for life, I've never seen someone that works in a functional style for ~15 years, transitions to imperative programming and never goes back. Those could exist, but the grain is almost always the other direction. I'm not sure why this is, but it is an interesting phenomena worth mentioning.
Conclusion
So the tl;dr is that fp-ts
is not in Meeshkan's webapp anymore (sorry @gcanti! I still <3 the project though...). But the more important thing I wanted to write about while it was still top of mind is why I took it out. Ultimately, we may put it back in, and we may even transition to using PureScript on the front end, but that would only ever be because there is a team-wide entente about the abstractions that underlie functional programming. Or we could go back to vanilla JavaScript. I'm completely open to the idea that, in 2-3 years, I could realize that ECMAScript 1 was perfection, we sped past it, and we really need to go back.
I say all this without really knowing who the intended audience is, but I could guess that there will be two sorts of readers:
- Someone who doesn't use functional programming regularly and ranges from militantly against it to curious but hasn't taken the plunge yet. If that's you, my advice would be to find a mentor (I'm always happy to mentor folks!) and let it sink in over n days/weeks/months/years (it took me 2-3 years to really grok fp).
- Someone who works regularly in functional languages. If that's you, and if you've survived in the industry, it's likely because you are not a fundamentalist and agree with many of these ideas. I would encourage you to share your knowledge, write articles and mentor others.
And as a parting shot, no matter what category you're in, at Meeshkan we're always looking for talented engineers that are a great culture fit! This includes, of course, those of you who like using fp-ts
:-D
Top comments (3)
sure there is some akwardness in writing in a typed fp style in typescript, because of no inherent language support for many features... so you get more boilerplate and code, I agree on that.
but working with fp-ts make sense when you decide to dive all in, or to refactor an entire module, not when taking apart one function and comparing it to the boilerplate free ts counterpart.
I think the real issue is, that you didn't convince your team(and yourself) of the gains for the price you pay,
working with fp-ts, you have consistant abstraction for computation, modularity, easy to test, to reason, simple refactoring, strong types throughout the system and so on, which many of these you can't get by simply working with ts, without falling back to creating your own abstractions that would often look awfully similar to the ones defined in fp-ts.
as for the part that talks about working in a team, understanding monads, tagless finals, free monads etc, I always find that argument weird...
no one works in a team without a structure, if you don't go the fp way, you go the OO way or some other way, each has it's jargons, it's just that in the OO world for example, you're just more accustomed to them (covariants, polymorphisem, inheritance, design patterns like visitor, builder, adapter and so on), they are not easier or harder, they just exists in a context where it make sense and you need to learn it, no difference here.
the main abstractions of fp-ts are super easy to learn and intuitive, it took me exactly 1 hour to explain 90% of what I use daily to my team, which had 0 previous experience in this way of writing or thinking.
no need to learn about category theory or monadic laws to use them, but it feels like in the fp community that once we grasp these concepts, which are really easy to explain how to use pragmatically, we just can't let go of their theory and insist to teach them the same way we struggled to learn, in a stupid loop.
Great that you got the pushback from Makenna 😊 I also got that much needed pushback when I suggested we should use io-ts for runtime type-checking, but luckily we ended up using zod. That turned out to be a much better choice for our codebase!
I — the 'lauder of hooks' and user of
tsx
loved this article! Thanks for the peek into your thinking on this 😁