Two ways of looking at map functions

Joël Quenneville

When learning a functional language, you’ll notice that map functions are everywhere. Coming from Ruby, I wasn’t familiar with map functions other than Array#map.

Now the best way to learn these is to actually use them and get a feel for what they do. After doing that, I noticed that among other things map functions solve two big problems:

  1. It’s tedious to wrap/unwrap data structures just to run functions on the values inside them.
  2. We need a way to translate “normal” functions we write to work on wrapped values like Maybe and Result.

If you squint, you may notice that these two problems are actually the same problem viewed from two different angles. So think of this as two different perspectives on mapping functions.

Problem 1 - Wrapping and unwrapping

You have a wrapper type and want to run a function on the value inside:

type Dollar = Dollar Int

Incrementing the dollar value looks like:

incrementDollar : Dollar -> Dollar
incrementDollar (Dollar d) =
  Dollar (d + 1)

I’m destructuring in the arguments to unwrap the value inside the Dollar. That’s a lot of wrapping and unwrapping needed to do something simple.

Trying to double a dollar looks similar:

doubleDollar : Dollar -> Dollar
doubleDollar (Dollar d) =
  Dollar (d * 2)

in both cases, we’re following the same three steps:

  1. Unwrapping the integer
  2. Doing something with the integer
  3. Re-wrapping the result from step 2

Step 2 is the only part that’s interesting. Steps 1 and 3 are a boilerplate-heavy wrap/unwrap sandwich.

There’s a principle in software development that states:

Separate things that change from things that stay the same.

We can abstract away the wrapping/unwrapping sandwich:

mapDollar : (Int -> Int) -> Dollar -> Dollar
mapDollar fn (Dollar d) =
  Dollar (fn d)

This looks just like our other two functions but now you can pass it an arbitrary function to use for step 2.

incrementDollar : Dollar -> Dollar
incrementDollar dollar =
  mapDollar (\d -> d + 1) dollar

Handling multiple arguments

What about multiple arguments? How about trying to add two dollar amounts?

addDollar : Dollar -> Dollar -> Dollar
addDollar (Dollar d1) (Dollar d2) =
  Dollar (d1 + d2)

We’re still doing three steps:

  1. Unwrap the two integers
  2. Calculate something based on the two integers
  3. Re-wrap the result of step 2

This is just like our mapDollar before but now we work with two arguments.

map2Dollar : (Int -> Int -> Int) -> Dollar -> Dollar -> Dollar
map2Dollar fn (Dollar d1) (Dollar d2) =
  Dollar (fn d1 d2)

Now we can say:

addDollar : Dollar -> Dollar -> Dollar
addDollar d1 d2 =
  map2Dollar (+) d1 d2

Problem 2 - Translating functions

You’ve written some functions for some basic number transformations:

increment : Int -> Int
increment n =
  n + 1

add : Int -> Int -> Int
add n1 n2 =
  n1 + n2

This works fine until you inevitably come across a number wrapped in Maybe. Great, now you have to re-implement versions of these functions that work on Maybe values. And you’ll probably have to do that again to deal with numbers wrapped in Result. If only there was a way to auto-translate your functions.

Well there is!

Notice that the signature of the functions is the same other than possibly being wrapped in Maybe or Result.

increment       :          Int ->          Int -- HAVE
incrementMaybe  : Maybe    Int -> Maybe    Int -- WANT
incrementResult : Result a Int -> Result a Int -- WANT

Enter the map function:

-- TRANSLATE increment TO WORK ON MAYBE

incrementMaybe : Maybe Int -> Maybe Int
incrementMaybe =
  Maybe.map increment
-- TRANSLATE increment TO WORK ON RESULT

incrementResult : Result a Int -> Result a Int
incrementResult =
  Result.map increment

maybe map increment

Multiple arguments

That’s cool but what about our add function? It takes two arguments. That’s what map2 is for (and map3 for 3-arg functions and so on).

Again, the signatures are similar other than the wrappers:

add :                Int ->          Int ->          Int -- HAVE
addMaybe  : Maybe    Int -> Maybe    Int -> Maybe    Int -- WANT
addResult : Result a Int -> Result a Int -> Result a Int -- WANT

Using map2, this would look like:

-- TRANSLATE add TO WORK ON MAYBE

addMaybe : Maybe Int -> Maybe Int -> Maybe Int
addMaybe =
  Maybe.map2 add
-- TRANSLATE add TO WORK ON RESULT

addResult : Result a Int -> Result a Int -> Result a Int
addResult =
  Result.map2 add

maybe map2 add

Patterns

Map functions are some of the most versatile and useful constructs in functional programming. As you work with them, you’ll start getting a feel for them. Perhaps you’re using them to make presence checks. Perhaps you’re using them to layer wrappers on other values.

Eventually some larger patterns emerge. You’ll start seeing how all these perspectives are just that: the same solution viewed from a different angle. With each new perspective on these little functions, you gain a clearer understanding of how where they fit in the world and are better able to see the problems that are best solved with a map.