Published on

Functional Programming: Mappables & Chainables

A brief foray into the world of functional programming
Authors
  • avatar
    Name
    Zafer Cesur
    Twitter
    @whizzaf
    Occupation
    Co-founder & CTO
an array of cats

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?

curry cat

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 from A to B into those from Array<A> to Array<B>
  • chain turns functions from A to Array<B> into those from Array<A> to Array<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?

cat in a box

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. Eithers. 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")
drake cat

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 error
  • Option: possibly failing computation, used to encapsulate an optional (nullable) value
  • Task: asynchronous computation
  • Reader: 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

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 both map (if you return a regular value) and chain (if you return a Promise), because it is overloaded to encompass both functionalities.
  • catch is used for error handling semantics, and
  • await 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 and chain syntax for all kinds of computations, each with its own semantics—which makes overloaded methods like then redundant
  • separate concerns orthogonally into different mappable/chainable data types (e.g. Task for async and Either 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.