Writing a Functional Programming-Style map Function
Robert Pearce
Posted on February 20, 2020
Many thanks to Helen Durrant for reviewing this post and offering stellar suggestions. Originally posted on https://robertwpearce.com/javascript-writing-a-functional-programming-style-map-function.html.
In this post, we will write a functional programming-style implementation of JavaScript's map
function that not only works with Array
but any data structure that implements a map
method. Such data structures are known as Functors
. Some examples of Functors
are the algebraic data types1 Maybe
and Async
(prior knowledge of them is not required, and out of the two, we'll only use Maybe
).
By the end of this post, you will:
- know how to implement a generic
map
function that includes functions formap
pingArray
s,Object
s, andFunctor
s - understand how to use
map
in a variety of scenarios - know how to write a simple
compose
function and use composition - know how to reliably test values for their types
- have received a small introduction to algebraic data types via the
crocks
library
This is a big post, so buckle up! If you want to see the final product, check out this CodeSandbox: https://codesandbox.io/s/bitter-grass-tknwb.
Note: if you're not familiar with Array.prototype.map
already, check out my video on Using JavaScript's Array.prototype.map Method or my post on JavaScript: Understand Array.prototype.map by Reimplementing It.
We will use the implementation of the map
function in crocks as our template, so if you want to skip this article entirely, you can go and view its source.
Overview
- The Goal:
map
All the Things - Defining Our
map
Function map
anArray
map
anObject
map
aFunction
map
aFunctor
throw
ing Out Bad Data
The Goal: map
All the Things
Today we are going to write a map
function that does the following:
- accepts a transformation function that takes in some argument of type
a
and transforms it into a value of typeb
; i.e.,(a -> b)
- accepts and handles any of the following data types:
Sounds easy, right? We'll see!
Defining Our map
Function
There are some things we already know about our map
function:
- it's called
map
(yay! nailed it!) - it takes a function (
fn
) and then some datum (m
2)3 - it returns the datum as transformed by said function
Let's sketch it out:
const map = (fn, m) => {
// ???
}
Okay, it's a start. This could conceivably be used like this:
map(x => x.id, [{ id: 1 }, { id: 2 }]) // [1, 2]
map(x => x.id, [{ id: 'a' }, { id: 'b' }]) // ['a', 'b']
Note the repetition of the x => x.id
. Let's try pulling it out into a
variable:
const propId = x => x.id
map(propId, [{ id: 1 }, { id: 2 }]) // [1, 2]
map(propId, [{ id: 'a' }, { id: 'b' }]) // ['a', 'b']
Alas, that's not much better – now we're just repeating the variable!
Instead, what if we could store our combination of function and map
in a variable and then use that to call with our different data? By partially applying the function to map
, we can!
const mapId = map.bind(null, x => x.id)
mapId([{ id: 1 }, { id: 2 }]) // [1, 2]
mapId([{ id: 'a' }, { id: 'b' }]) // ['a', 'b']
Nice! Now, let's go back to our sketch. Let's turn our binary function (which takes two parameters) to instead be a series of unary functions (which take one parameter4).
const map = fn => m => {
// ???
}
Wow, that was easy. By default, languages like Haskell and Elm automatically curry all of their function parameters. There are ways to automate that in JavaScript, but for today, we will manually curry functions by using arrow functions to simulate it: const sum = a => b => a + b
, for example.
Lastly, on the function definition side, it would be helpful for readers of our code to understand more about the intended types. In lieu of JavaScript not having a static type checker and me not knowing TypeScript yet, we'll do this using a Haskell-style pseudo-type signature:
map :: Functor f => (a -> b) -> f a -> f b
And we can place that as a comment above our function:
// map :: Functor f => (a -> b) -> f a -> f b
const map = fn => m => {
// ???
}
Woah, woah, woah! What's all this? Let's break it down.
map :: Functor f => (a -> b) -> f a -> f b
-- | | | | | |
-- 1 2 3 4 5 6
- Can be read, "has the type of"
- Anything after
::
and before=>
in a signature is a class constraint. This says we're going to use something in the type signature that obeys the Functor Laws5, identity and composition. The lowercasef
represents what theFunctor
will be in the signature. - Our
map
ping function; e.g.,x => x.id
, like we did above. -
->
Arrows are used in type signatures to say "then return...". In ourmap
signature, we say, "We accept a function froma
tob
then return a function that acceptsf
ofa
and then returnf
ofb
". If we were summing three numbers,sum3 :: Number -> Number -> Number -> Number
, this would read, "sum3
has the type of an expression that accepts aNumber
that returns a function that accepts aNumber
then returns a function that accepts aNumber
and then returns aNumber
." -
f a
says that aFunctor
,f
, wraps some other type,a
. A concrete example of this is[Number]
, which is a list (orArray
) ofNumber
s. -
f b
says that aFunctor
,f
, wraps some other type,b
. Why isn't ita
? This signifies that when we take in theFunctor
of any typea
, it's totally cool if you want to change the return type inside theFunctor
. For example, when we take[{ id: 'a' }, { id: 'b' }]
and usemap
to turn that into['a', 'b']
, we're taking[Object]
(a list ofObject
s) and turning that into[String]
(a list ofString
s).
All together now! "map
has the type of an expression where f
is a Functor
, and it accepts a function from a
to b
, then returns a function that accepts f
of a
, and then returns f
of b
."
map
an Array
Let's map
an Array
!
Remember our Functor
class constraint?
map :: Functor f => (a -> b) -> f a -> f b
Guess what? Array
is a Functor
s! How? It adheres to the laws of identity and composition:
// identity
[1,2,3].map(x => x) // [1,2,3]
// composition
const add10 = x => x + 10
const mult2 = x => x * 2
[1,2,3].map(add10).map(mult2) // [ 22, 24, 26 ]
// is equivalent to...
[1,2,3].map(x => mult2(add10(x))) // [ 22, 24, 26 ]
// another example of the composition law
const compose = (f, g) => x => f(g(x))
mult2(add10(2)) === compose(mult2, add10)(2) // true
// and applied back to our prior example
[1,2,3].map(add10).map(mult2) // [ 22, 24, 26 ]
[1,2,3].map(x => mult2(add10(x))) // [ 22, 24, 26 ]
[1,2,3].map(compose(mult2, add10)) // [ 22, 24, 26 ]
Through map
, Array
is a Functor
. A way to quickly determine if something is a Functor
is to ask, "Does it implement map
/ is it map
pable?"
Since we know that Array
is map
pable, we can use our map
function to check if the f a
parameter is an Array
and then use the build in Array.prototype.map
function to get from a
to b
:
// map :: Functor f => (a -> b) -> f a -> f b
const map = fn => m => {
if (isArray(m)) {
return mapArray(fn, m)
}
}
// isArray :: a -> Bool
const isArray = x => Array.isArray(x)
// mapArray :: ((a -> b), Array a) -> Array b
const mapArray = (fn, m) => m.map(x => fn(x))
Here, we use Array.isArray()
6 to see if the argument, m
, is an Array
, then we call a function, mapArray
, that handles the map
ping of the Array
.
You might be thinking: why m.map(x => fn(x))
and not m.map(fn)
? As you might remember from my article on re-implementing Array.prototype.map
, there are a few other arguments that the native implementation of map
provide, as well as some potential changes to the this
keyword in your callback function scope. Instead of allowing those to pass through, we simply take the first argument, the currently iterated value, and send that to the callback function7.
Now that we've seen the easy way to do map
with Array
, let's see what this would look like if we felt like implementing mapArray
ourselves:
// mapArray :: ((a -> b), Array a) -> Array b
const mapArray = (fn, m) => {
const newArray = []
for (let i = 0; i < m.length; i++) {
newArray[i] = fn(m[i])
}
return newArray
}
Not too shabby! All we do is create a new Array
and set the results of calling the callback function with each item to its index in the new Array
and then return that Array
.
Do you think our map
function can handle an Array
of Array
s?
map(x => x * 2)([ [1,2], [3,4], [5,6] ])
// Array(3) [ NaN, NaN, NaN ]
While we can successfully iterate over the 3 items in the top-level Array
, our callback function can't perform operations like [1,2] * 2
! We need to do another map
on the nested Array
s:
map(map(x => x * 2))([ [1,2], [3,4], [5,6] ])
// [ [2,4], [6,8], [10,12] ]
Well done! What else can you map
? We're now going to leave charted waters and venture into the unknown.
map
an Object
Let's say we have an i18n
(short for "internationalization") object that we've been given that has a terribly annoying issue: every translation is prefixed and suffixed with an underscore (_
)!
const i18n = {
'en-US': {
dayMode: '_Day mode_',
greeting: '_Hello!_',
nightMode: '_Night Mode_'
},
'es-ES': {
dayMode: '_Modo día_',
greeting: '_¡Hola!_'
nightMode: '_Modo nocturno_'
}
}
We could manually delete each one, or we could find and replace with our text editor, or we could write a for
loop to do this, but because we're super awesome functional programmers, we'll try to map
over the Object
and write a function that removes the prefixed & suffixed underscores (...then we copy and paste that? work with me here!).
Before we can do this, we need to see what happens when we call .map()
on an Object
:
i18n['en-US'].map(x => x.slice(1))
// TypeError: i18n['en-US'].map is not a function
Oh no! If we can't even fix the en-US
Object
, how are we supposed to fix all of them? Let's update our map
function to handle Object
s.
// map :: Functor f => (a -> b) -> f a -> f b
const map = fn => m => {
if (isArray(m)) {
return mapArray(fn, m)
}
if (isObject(m)) {
return mapObject(fn, m)
}
}
// isObject :: a -> Bool
const isObject = x =>
!!x && Object.prototype.toString.call(x) === '[object Object]'
// mapObject :: ((a -> b), { k: a }) -> { k: b }
const mapObject = (fn, m) => {
const obj = {}
for (const [k, v] of Object.entries(m)) {
obj[k] = fn(v)
}
return obj
}
Here, we test if something is an object by using Object.prototype.toString
and make sure to .call(x)
instead of just .toString(x)
, for this reason:
Object.prototype.toString(null)
// "[object Object]"
Object.prototype.toString.call(null)
// "[object Null]"
Object.prototype.toString([])
// "[object Object]"
Object.prototype.toString.call([])
// "[object Array]"
Object.prototype.toString.call({})
// "[object Object]"
We then use our new mapObject
function, whose signature is
mapObject :: ((a -> b), { k: a }) -> { k: b }
mapObject
takes a function from a
to b
and an Object
with a key(s) and some value(s), a
, and returns an Object
with a key(s) and some value(s) b
. In short, it maps the values of an Object
. Our mapObject
function is nothing more than a for
loop over each value returned from Object.entries()
! It calls the callback function with each value and returns a new object with the same key and a new, updated value.
Let's try it out:
const i18n = {
'en-US': {
dayMode: '_Day mode_',
greeting: '_Hello!_',
nightMode: '_Night Mode_'
},
'es-ES': {
dayMode: '_Modo día_',
greeting: '_¡Hola!_'
nightMode: '_Modo nocturno_'
}
}
map(x => x.slice(1, -1))(i18n['en-US'])
// {
// dayMode: 'Day mode',
// greeting: 'Hello!',
// nightMode: 'Night Mode'
// }
Okay – what about our entire i18n
object?
map(map(x => x.slice(1, -1)))(i18n)
// {
// 'en-US': {
// dayMode: 'Day mode',
// greeting: 'Hello!',
// nightMode: 'Night Mode'
// },
// 'es-ES': {
// dayMode: 'Modo día',
// greeting: '¡Hola!',
// nightMode: 'Modo nocturno'
// }
// }
Since we're dealing with nested objects, we need to use map
on an Object
inside an Object
. We pass a nested map
ping function, and our little underscore problem is gone!
map
a Function
Remember our functions mult2
and add10
from before?
const add10 = x => x + 10
const mult2 = x => x * 2
What would happen if we used those as the arguments to our map
function and wanted them to be automatically composed together so that we can then provide a value later?
map(add10)(mult2) // undefined
map(add10)(mult2)(12) // TypeError: map(...)(...) is not a function
Time for our map
function to handle a Function
as the second argument and compose
the two functions together:
// map :: Functor f => (a -> b) -> f a -> f b
const map = fn => m => {
if (isArray(m)) {
return mapArray(fn, m)
}
if (isObject(m)) {
return mapObj(fn, m)
}
if (isFunction(m)) {
return compose(fn, m)
}
}
// isFunction :: a -> Bool
const isFunction = x => typeof x === 'function'
// compose :: ((b -> c), (a -> b)) -> a -> c
const compose = (f, g) => x => f(g(x))
And when we run our previously failed code again,
map(add10)(mult2) // function compose(x)
map(add10)(mult2)(12) // 44
we can see that calling map
with two functions returns a composition of those two functions, and calling that result with a primitive value (12
) gives us back our result, 44
.
map
a Functor
When we learned about map
ping Array
s before, we learned that Array
s are Functor
s because they adhere to the laws of identity and composition; i.e., they are map
pable.
There are all sorts of other data structures that implement a map
method, just like Array.prototype
does, and we want to be able to handle those, too!
We currently have all the tools required to implement map
for Functor
s without even knowing how they might work! All we need to know is, "Does it implement map
as a Function
?" Let's see what we can come up with!
// map :: Functor f => (a -> b) -> f a -> f b
const map = fn => m => {
if (isFunction(m)) {
return compose(fn, m)
}
if (isArray(m)) {
return mapArray(fn, m)
}
if (isFunctor(m)) {
return mapFunctor(fn, m)
}
if (isObject(m)) {
return mapObj(fn, m)
}
}
// isFunction :: a -> Bool
const isFunction = x => typeof x === 'function'
// isFunctor :: a -> Bool
const isFunctor = x => !!x && isFunction(x['map'])
// mapFunctor :: Functor f => ((a -> b), f a) -> f b
const mapFunctor = (fn, m) => m.map(fn)
That is surprisingly simple, isn't it? We use our isFunction
check from before to test if m
has a map
property that is a Function
, then we call map
on m
and pass it the callback Function
in mapFunctor
.
You might be thinking that mapArray
and mapFunctor
could use the same handler because Array
s are Functors
, and you are correct; however, because of the extra implementation bits that come back from Array.prototype.map
, we'll keep them separate and only call the callback to Array.prototype.map
with the currently iterated item. Here's the difference:
// mapArray :: ((a -> b), Array a) -> Array b
const mapArray = (fn, m) => m.map(x => (fn(x))
// mapFunctor :: Functor f => ((a -> b), f a) -> f b
const mapFunctor = (fn, m) => m.map(fn)
If you don't care about this, it's totally acceptable to not include the Array
bits at all and use the Functor
map
8 to handle the map
ping of Array
s, since they're Functor
s.
To test our Functor
map
ping, we'll use crocks to provide us access to an algebraic data type called Maybe
.
import { compose, option, prop } from 'crocks'
const company = {
name: 'Pearce Software, LLC',
locations: [
'Charleston, SC, USA',
'Auckland, NZ',
'London, England, UK'
]
}
prop('foo', company) // Nothing
prop('locations', company) // Just [String]
option([], prop('foo', company))
// []
option([], prop('locations', company))
// [
// 'Charleston, SC, USA',
// 'Auckland, NZ',
// 'London, England, UK'
// ]
const getLocations = compose(option([]), prop('locations'))
getLocations(company)
// [
// 'Charleston, SC, USA',
// 'Auckland, NZ',
// 'London, England, UK'
// ]
Pump the breaks! What's all this Just
and Nothing
stuff? We're not going to focus on Maybe
s today9, but the short version is that the locations
property may or may not be present in the object, so we encapsulate that uncertainty inside of a Maybe
algebraic data type via the prop
function, and we provide a default value via the option
function that the Maybe
can fall back to in the event of not being able to find locations
.
Why does this matter? We want to map
a Maybe
, and the prop
function will give us access to one. Let's see what it looks like:
import { compose, option, prop } from 'crocks'
const upcase = x => x.toUpperCase()
const getLocations =
compose(option([]), map(map(upcase)), prop('locations'))
getLocations({}) // []
getLocations(company)
// [
// 'CHARLESTON, SC, USA',
// 'AUCKLAND, NZ',
// 'LONDON, ENGLAND, UK'
// ]
Okay, cool! But why are we map
ping twice?
When we work with algebraic data types like Maybe
, instead of writing if (dataIsValid) doSomething
, the map
method on a Maybe
gives us access to the value inside the Maybe
(our locations
), but it does so only if the data is available.
Once we have access to the locations
, we then use map
again to uppercase each location.
throw
ing Out Bad Data
What happens if the arguments passed to map
aren't a Function
and a Functor
?
map(null)([1,2,3]) // TypeError: fn is not a function
map(x => x * 2)(null) // undefined
map(null)(null) // undefined
I think we can provide some more helpful messaging to guide users of our map
tool on how to use it correctly.
// map :: Functor f => (a -> b) -> f a -> f b
const map = fn => m => {
if (!isFunction(fn)) {
throw new TypeError(`map: Please provide a Function for the first argument`)
}
// ...our other handlers...
throw new TypeError(`map: Please provide a Functor or Object for the second argument`)
}
map(null)([1,2,3]) // TypeError: map: Please provide a Function for the first argument
map(x => x * 2)(null) // TypeError: map: Please provide a Functor or Object for the second argument
map(null)(null) // TypeError: map: Please provide a Function for the first argument
Now, when we provide bad arguments, we're told exactly what we need to do.
Wrapping Up
Congratulations and thank you for making it to the end! If you want to play around with what we created, check out this CodeSandbox: https://codesandbox.io/s/bitter-grass-tknwb.
Here is our code from today in its entirety:
const { compose, option, prop } = require('crocks')
// map :: Functor f => (a -> b) -> f a -> f b
const map = fn => m => {
if (!isFunction(fn)) {
throw new TypeError(`map: Please provide a Function for the first argument`)
}
if (isFunction(m)) {
return compose(fn, m)
}
if (isArray(m)) {
return mapArray(fn, m)
}
if (isFunctor(m)) {
return mapFunctor(fn, m)
}
if (isObject(m)) {
return mapObj(fn, m)
}
throw new TypeError(`map: Please provide a Functor or Object for the second argument`)
}
// we're opting for crocks' compose, instead
// compose :: ((b -> c), (a -> b)) -> a -> c
// const compose = (f, g) => x => f(g(x))
// isArray :: a -> Bool
const isArray = x => Array.isArray(x)
// isFunction :: a -> Bool
const isFunction = x => typeof x === 'function'
// isFunctor :: a -> Bool
const isFunctor = x => !!x && isFunction(x['map'])
// isObject :: a -> Bool
const isObject = x =>
!!x && Object.prototype.toString.call(x) === '[object Object]'
// mapArray :: ((a -> b), Array a) -> Array b
const mapArray = (fn, m) => {
const newArray = []
for (let i = 0; i < m.length; i++) {
newArray.push(fn(m[i]))
}
return newArray
}
// realistically, you should use this mapArray:
// const mapArray = (fn, m) => m.map(x => fn(x))
// mapObj :: (a -> b) -> { k: a } -> { k: b }
const mapObj = (fn, m) => {
const obj = {}
for (const [k, v] of Object.entries(m)) {
obj[k] = fn(v)
}
return obj
}
// mapFunctor :: Functor f => ((a -> b), f a) -> f b
const mapFunctor = (fn, m) => m.map(fn)
Thank you for reading!
Robert
-
https://github.com/hemanth/functional-programming-jargon#algebraic-data-type ↩
-
Wondering why the data comes last? Check out Brian Lonsdorf's "Hey Underscore, You're Doing It Wrong!" talk. The tl;dr is that you should arrange your arguments from least likely to change to most likely to change in order to pave the way for partial application and greater code reuse. ↩
-
https://github.com/hemanth/functional-programming-jargon#arity ↩
-
https://github.com/hemanth/functional-programming-jargon#functor ↩
-
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray ↩
-
Check out ramda.js'
addIndex
function to see a different pattern for working with indices andArray
s. ↩ -
If you're an egghead.io subscriber, Andy Van Slaars has a great course, Safer JavaScript with the Maybe Type, or you can check out a Haskell article on The Functor class. ↩
Posted on February 20, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
August 16, 2020