- Published on
Functional Programming: Mappables & Chainables
- Authors
- Name
- Zafer Cesur
- @whizzaf
- Occupation
- Co-founder & CTO
This post is a brief foray into the world of functional programming. For demonstration purposes, we'll be using TypeScript since it is perhaps the most approachable and mainstream language that could pass as a statically typed FP language. Some examples will additionally be relying on the fp-ts
library.
Now, unlike most posts on FP that you might see on the internet, this one will neither be theoretical nor thorough. If you're interested in something like that, I'd recommend checking out the excellent book Professor Frisby’s Mostly Adequate Guide to Functional Programming.
Instead, we'll be handwavy at times and take a more practical approach by starting with problems and working towards solutions which employ concepts that make FP so great such as data transformation pipelines, higher-order functions and composability.
Enough rambling—let's get to it! First we should define some cats
type Cat = {
name: string
age: number
food: Array<string>
}
const maple = { name: 'maple', age: 1, food: ['salmon', 'mackerel'] }
const lyon = { name: 'lyon', age: 2, food: ['chicken', 'eggs'] }
const beans = { name: 'beans', age: 3, food: ['pinto beans'] }
const cats = [maple, lyon, beans]
and a simple function that we can use on our cats:
const age: (c: Cat) => number = (c) => c.age
age(maple)
// 1
Higher-order functions
Here's a problem: how can we use this function to get the ages of all of our cats? Easy peasy, you might say. Just map
it on the array like so:
cats.map(age)
// [1, 2, 3]
Very well! Here is another challenge: suppose we also wanted to get an array consisting of the favorite food of our cats. Shall we use map
again? Let's see what happens.
const food: (c: Cat) => Array<string> = (c) => c.food
food(maple)
// ["salmon", "mackerel"]
cats.map(food)
// [["salmon","mackerel"],["chicken","eggs"],["pinto beans"]]
Hmm, we get an array of arrays—not quite what we wanted. Luckily there is another function just for these kinds of situations: flatMap
!
cats.flatMap(food)
// ["salmon", "mackerel", "chicken", "eggs", "pinto beans"]
Problem solved.
Currying
Are you feline spicy?
While the above solution's all well and good, let's try to solve these problems in a slightly different way—for reasons that will become clear later in this post. In order to do that, we'll need to shift our perspective a little.
So both map
and flatMap
take an array and a function, and return another array. What if they only took a function and returned another function that takes an array? In other words, what if they "lifted" our regular functions and turned them into array functions? There are analogues of map
and flatMap
in the fp-ts
library that work just like that, called map
and chain
, respectively. Let's see them in action:
import * as A from 'fp-ts/lib/Array'
const age: (c: Cat) => number = (c) => c.age
const ages: (a: Array<Cat>) => Array<number> = A.map(age)
age(maple)
// 1
ages(cats)
// [1, 2, 3]
const food: (c: Cat) => Array<string> = (c) => c.food
const foods: (a: Array<Cat>) => Array<string> = A.chain(food)
foods(cats)
// ["salmon", "mackerel", "chicken", "eggs", "pinto beans"]
So, in a nutshell,
map
turns functions fromA
toB
into those fromArray<A>
toArray<B>
chain
turns functions fromA
toArray<B>
into those fromArray<A>
toArray<B>
Okay, that's kind of cool... I guess. But why do we care, right? As we've already seen, we can just use map
and flatMap
to solve the exact same problems. Plus, we wouldn't need to import an entire library.
Error handling
In order to really appreciate map
and chain
, we need to look at some other data types that are not arrays. To motivate our next data type, consider this: how might we write a function that returns a cat given a name? Since we don't have a cat ready for all possible names, we need to handle the case where we are asked a cat we don't have.
Exception
How about we just throw an error?
// This is a lie
// v
const catFromNameExn: (name: string) => Cat = (name) => {
switch (name) {
case 'maple':
return maple
case 'lyon':
return lyon
case 'beans':
return beans
default:
throw Error('Cat not found')
}
}
The good bad news is that this piece of code compiles fine. But we're blatantly lying here when we say in the signature that our function always returns a Cat
when most of the time it actually throws an error. In order to use this function properly one needs to read the documentation at best or the actual code at worst. Can we do better? Can we perhaps lift this insight that we have about missing cats into the type system?
Union type
How about we return the error instead of throwing it?
// Proudly admitting the truth
// v
const catFromName: (name: string) => Cat | Error = (name) => {
switch (name) {
case 'maple':
return maple
case 'lyon':
return lyon
case 'beans':
return beans
default:
return Error('Cat not found')
}
}
This changes the return type of our function into a union of Cat
and Error
types. That means, if someone uses this function and doesn't handle the error case, they will get a compile-time error:
const cat = catFromName('max')
cat.age
// Property 'age' does not exist on type 'Cat | Error'.
// Property 'age' does not exist on type 'Error'.ts(2339)
Nice, we have literally made it impossible to misuse our function! How about another function that returns the favorite type of beans of a cat?
const favBeans: (c: Cat) => string | Error = (c) => {
for (const f of c.food) {
if (f.includes('beans')) {
return f
}
}
return Error('Provided cat does not eat beans')
}
Cool beans! Now we should be able to combine these into a single function that returns the favorite type of beans of a cat, given only its name:
const favBeansFromName: (name: string) => string | Error = (name) => {
const catOrErr = catFromName(name)
if (catOrErr instanceof Error) {
return catOrErr
}
const beansOrErr = favBeans(catOrErr)
if (beansOrErr instanceof Error) {
return beansOrErr
}
return beansOrErr
}
favBeansFromName('max')
// Error("Cat not found")
favBeansFromName('beans')
// "pinto beans"
Hmm... This certainly works fine, but 2/3s of the code here is just to handle the errors. For such a simple function, this is embarrassingly hard to read because of all the noise. Compare it with the exception-throwing version:
const favBeansFromNameExn: (name: string) => string = (name) => {
const cat = catFromNameExn(name)
const beans = favBeansExn(cat)
return beans
}
So are we doomed to choose between safety and ergonomics? Luckily, the answer is no. Enter Either
.
Either
// fp-ts/lib/Either
type Either<E, A> = Left<E> | Right<A>
Either
builds upon our idea of using a union type. The difference is that, we construct an instance of Either
by wrapping our value in Left
or Right
instead of using it directly. By convention, Left
is used to hold an error value and Right
is used to hold a correct value. Let's see it in action:
import * as E from 'fp-ts/lib/Either'
import Either = E.Either
const catFromName: (name: string) => Either<Error, Cat> = (name) => {
switch (name) {
case 'maple':
return E.right(maple)
case 'lyon':
return E.right(lyon)
case 'beans':
return E.right(beans)
default:
return E.left(Error('Cat not found'))
}
}
So our new function neither returns a cat nor an error, but instead it returns a box that contains either a cat or an error. Kinda like the Schrödinger's box! But what do we do with this box? For instance, how can we take the cat outside the box, assuming there is one, so that we can find out how old she is?
What if we didn't have to do all that? Remember how we didn't actually have to concern ourselves with the internals of our cats
array earlier when we applied the age
function to each cat? Drawing a parallel to arrays, the Either
type can essentially be thought of as a container: if it's a "left" box, it has 0 (meaningful) elements, and if it's a "right" box, it has precisely 1 element.
Using the same idea, we can lift our function that operates on cats into a function that operates on Schrödinger's boxes, a.k.a. Either
s. To do that, you don't need to look any further than our trusted map
—this time the Either
instance of it:
// If you squint a little, you might notice that Either<Error, _> is just
// like Array<_>
const age: (c: Cat) => number = (c) => c.age
const ageA: (a: Array<Cat>) => Array<number> = A.map(age)
const ageE: (e: Either<Error, Cat>) => Either<Error, number> = E.map(age)
const mapleBox = catFromName('maple')
const maxBox = catFromName('max')
ageE(mapleBox)
// E.right(1)
ageE(maxBox)
// E.left(Error("Cat not found"))
How does this work? Well, our lifted function ageE
takes a box and checks what kind of box it is behind the scenes. If it's a right box, it applies age
to the cat inside; otherwise it does nothing and returns the box as is. By the same token, we can write
const favBeans: (c: Cat) => Either<Error, string> = (c) => {
for (const f of c.food) {
if (f.includes('beans')) {
return E.right(f)
}
}
return E.left(Error('Provided cat does not eat beans'))
}
const favBeansE: (e: Either<Error, Cat>) => Either<Error, string> = E.chain(favBeans)
favBeansE(mapleBox)
// E.left(Error("Provided cat does not eat beans"))
favBeansE(catFromName('beans'))
// E.right("pinto beans")
So once we write a cat function, we can reuse it on all kinds of different data types (like Array
, Either
etc.) by just lifting it into the appropriate context using map
or chain
!
What's more is that by utilizing a helper function called pipe
we can do away with defining these intermediate functions like ageE
or favBeansE
by composing these smol cat functions into a pipeline:
import { pipe } from 'fp-ts/lib/function'
pipe(
catFromName('maple'),
E.chain(favBeans),
E.map((s) => s.toUpperCase())
)
// E.left(Error("Provided cat does not eat beans"))
If you'd like a bit more symmetry, we could equivalently also put the name inside a right box and chain catFromName
like so:
pipe(
E.of('beans'), // same as E.right("beans")
E.chain(catFromName),
E.chain(favBeans),
E.map((s) => s.toUpperCase())
)
// E.right("PINTO BEANS")
Finally we are fully equipped to rewrite our function above in a fully type-safe and ergonomic way:
const favBeansFromName: (name: string) => Either<Error, string> = (name) =>
pipe(E.of(name), E.chain(catFromName), E.chain(favBeans))
Aaand that's it. Notice how there isn't a single line of error handling code! We get all of that for free from the implementations of map
and chain
for the Either
data type, which short-circuits remaining computations upon the first error. It works just as before:
favBeansFromName('max')
// E.left(Error("Cat not found"))
favBeansFromName('maple')
// E.left(Error("Provided cat does not eat beans"))
favBeansFromName('beans')
// E.right("pinto beans")
Closing notes
Array
and Either
together form just the tip of the iceberg that is the mappable and chainable data types, which are technically known as functors and monads, respectively. These data types appear everywhere and are used to model a wide range of kinds of computation. To give a few examples from the fp-ts
library, along with what each of them represents,
Either
: possibly failing computation, used to encapsulate a value which is potentially an errorOption
: possibly failing computation, used to encapsulate an optional (nullable) valueTask
: asynchronous computationReader
: computation within a context/environment
Moreover, we can stack these mappable/chainable structures on top of one another to get new mappable/chainable structures that have all the encapsulated features. For example, we have
TaskEither
: asynchronous computation that might failReaderTaskEither
: asynchronous computation within a context that might fail
and so on, all with their own implementations of map
and chain
derived from those of each individual structure.
Orthogonal design leads to simplicity
As a final closing note, let's consider how our favBeansFromName
function would look like if the functions that it's composed of (e.g. favBeans
and catFromName
) were of a different kind. What do I mean? In our example, these functions were such that they could fail. As a reminder, this is how it looked like:
import * as E from 'fp-ts/lib/Either'
import Either = E.Either
const favBeansFromName: (name: string) => Either<Error, string> = (name) =>
pipe(E.of(name), E.chain(catFromName), E.chain(favBeans))
What if these functions were such that they returned arrays instead? For example they could return a singleton array if the function is successful and an empty one otherwise. Then our pipeline would look like this:
import * as A from 'fp-ts/lib/Array'
const favBeansFromName: (name: string) => Array<string> = (name) =>
pipe(
A.of(name), // same as [name]
A.chain(catFromName),
A.chain(favBeans)
)
What if they were asynchronous functions that always resolved successfully? Maybe calling catFromName
would make a call to a server which would then create a cat with that name in the database and return that, or something. You get the idea. Then, we'd have
import * as T from 'fp-ts/lib/Task'
import Task = T.Task
const favBeansFromName: (name: string) => Task<string> = (name) =>
pipe(
T.of(name), // same as () => Promise.resolve(name)
T.chain(catFromName),
T.chain(favBeans)
)
Let's do a final one. What if we had the original failable functions, but they were now asynchronous? Then, our code would look something like
import * as TE from 'fp-ts/lib/TaskEither'
import TaskEither = TE.TaskEither
const favBeansFromName: (name: string) => TaskEither<Error, string> = (name) =>
pipe(
TE.of(name), // same as () => Promise.resolve(E.of(name))
TE.chain(catFromName),
TE.chain(favBeans)
)
Isn't this great? No matter what kind of functions we are working with, our code looks exactly the same. There's no error-handling in the Either
case, no looping in the Array
case et cetera. Even the asynchronous code looks the same as the ordinary synchronous one.
Now, consider the Promise
type along with the toolchain that comes with it:
then
is kinda like bothmap
(if you return a regular value) andchain
(if you return aPromise
), because it is overloaded to encompass both functionalities.catch
is used for error handling semantics, andawait
syntax makes it possible to structure async code in a way that is similar to synchronous code.
Nevertheless, all of these additional things add to the complexity of the language. In contrast, by adopting the FP toolchain, we can
- use the same
map
andchain
syntax for all kinds of computations, each with its own semantics—which makes overloaded methods likethen
redundant - separate concerns orthogonally into different mappable/chainable data types (e.g.
Task
for async andEither
for error handling) that we can compose (e.g.TaskEither
)—which makes methods and control flow structures such as (try
)-catch
redundant - employ data pipelines and higher-order functions to structure code that looks the same whether it is synchronous, asynchronous, error-handling etc.—which makes language features like
await
redundant
It goes to show that, instead of adding ad-hoc syntax and semantics, we can achieve the same, if not more of the desired features by choosing the right abstractions.