Functor: The Real Meaning of .map() (Part 9 of the Functional Programming Series)

Part 9 of the Functional Programming in Practice series. Part 7 gave us category theory, Part 8 used it for Monoids. This part uses it for the most famous functional tool of all, and reveals what .map() was secretly doing the whole time.

Hey there, Coding Chefs! 👨‍💻

You have called .map() on an array thousands of times. [1, 2, 3].map(x => x * 2). It is muscle memory. But have you ever wondered why it is called "map," and not "transform" or "each" or "convert"? That name is not arbitrary. It comes from one of the deepest ideas in functional programming, and once you see it, you will realize that the array .map() is just one instance of something far bigger, the thing that also powers Option, Either, Promises, and every "wrapped value" you will ever work with.

Remember the unfinished business from when we killed null? We made divide2 return an Option, but then it would not compose with increment, because increment wanted a plain number, not an Option. We promised a clean, beautiful way to run a normal function "inside" a wrapped value without hand-unwrapping every time. That way is the Functor, and .map() is how you spell it. By the end of this part the unfinished business is finished, and .map() will never look the same. Let's get cracking.

A Functor, From the Category-Theory Map

Pull out the category-theory map from earlier. A category is objects, arrows, and lawful composition. A Functor is a structure-preserving mapping between categories. It takes the dots and arrows of one category and lays them onto another in a way that keeps the structure intact.

I know that is abstract, so here is the human intuition the math is capturing. Remember that humans recognize patterns inside patterns. If a child draws a human as a stick figure, they have mapped the structure of a real human, head connected to body connected to limbs, onto a simpler drawing. The drawing is not the human, but the connections are preserved. You can still tell which part is the head. That preservation of structure is exactly what a Functor does. It maps one structure onto another and keeps the relationships intact.

For us, working in the category of types and functions, a Functor is a type constructor (something like Option, Array, Either, that wraps a type) that comes with a lawful way to apply a function inside it. The operation that does this is called map (in some languages fmap).

What map Actually Does

Here is the killer idea, stated plainly. map lifts a plain function so it can work inside a context, without you touching the original function.

Take isEven, an ordinary function we have used since the start:

const isEven = (n: number): boolean => n % 2 === 0;

It works on a plain number. isEven(12) is true. Now suppose your number is wrapped in an Option, because it might be absent. You cannot call isEven on an Option<number> directly, the types do not fit, that was the exact wall we hit.

map builds the bridge. map(isEven) takes your Option<number> and gives back an Option<boolean>. If the Option is a Some, it reaches inside, applies isEven to the value, and rewraps the result. If it is a None, there is nothing to apply it to, so it just returns None untouched.

const mapOption =
  <A, B>(f: (a: A) => B) =>
  (o: Option<A>): Option<B> =>
    o.tag === "none" ? none : some(f(o.value));

mapOption(isEven)(some(12)); // some(true)   -> reached in, applied isEven
mapOption(isEven)(none);     // none          -> nothing to do, passed through

Look at what just happened to the messy part. That if (some) ... else (none) check, the boilerplate I made you write by hand when we built Option? It is written exactly once, here, inside map. From now on you never write it again. You just call map(yourFunction) and absence is handled for you, every time. That is the unfinished business from the Option part, finished. divide2 and increment compose now: map(increment) lifts increment to work on the Option<number> that divide2 returns.

The Same Map, Everywhere

The beautiful part is that map is not special to Option. Any Functor has one, and they all follow the same intuition: reach into the context, apply the function, keep the context.

Array is a Functor. This is the one you already know. Array's map applies a function to each element and keeps the array shape. Where Option's map handled "zero or one value," Array's map handles "zero or many."

[1, 2, 3].map(isEven); // [false, true, false]

That is literally Array.prototype.map. Now you know why it is named map: it is the Functor map, implemented for arrays. Same idea, same name, on purpose.

Either is a Functor. Recall Either, with a Left (error) and Right (success). Its map applies the function to the Right value and passes a Left through untouched, exactly like Option passed None through:

const mapEither =
  <E, A, B>(f: (a: A) => B) =>
  (e: Either<E, A>): Either<E, B> =>
    e.tag === "left" ? e : right(f(e.value));

mapEither(increment)(right(4));        // right(5)
mapEither(increment)(left("bad input")); // left("bad input") -> untouched

Read what that gives you for free: you can run pure functions on the success track of an Either, and the error track is carried along automatically, no error handling re-implemented. The railway from the Either part and the Functor from this part are the same machinery viewed from two angles.

So map is one concept with many homes. Option, Array, Either, and later Promise/Task. Each is a different kind of context, "maybe absent," "many values," "value or error," "value arriving later," and map is how you apply ordinary functions inside any of them without unwrapping.

The Functor Laws

A type constructor with a map is only a real Functor if its map obeys two laws. These are what guarantee map actually preserves structure and does not secretly misbehave.

Identity law: mapping the do-nothing function over a structure gives back the same structure. map(x => x) is the same as doing nothing at all.

map(identity)(someValue) // must equal someValue, unchanged

Composition law: mapping f and then mapping g is the same as mapping the composition of g and f in a single pass.

map(g)(map(f)(x)) // must equal  map(compose(g, f))(x)

These are not red tape. The composition law is what lets you fuse two passes into one, and it is what guarantees map behaves predictably no matter how you arrange your transformations. Here is the honest TypeScript caveat, the same one from the Monoid part: TypeScript cannot enforce these laws. A broken map will compile. You uphold the laws by discipline, and you verify them with property-based tests (the technique waiting in the final part) or prove them on paper. When you define a custom Functor, checking the two laws is your job.

The Pipeline Payoff

Now the practical magic. Suppose you have a clean pipeline of plain functions, built with the composition we learned earlier:

const isEven   = (n: number): boolean => n % 2 === 0;
const toStr    = (b: boolean): string => `${b}`;
const toUpper  = (s: string): string => s.toUpperCase();
// pass 12 through these and you get "TRUE"

This works on a plain number. But now imagine the input comes from an external API that might give you nothing. You do not want to rewrite isEven, toStr, and toUpper to each handle absence, that would poison three innocent functions with null-checking, exactly the disease we diagnosed when we killed null.

Instead, lift the whole pipeline into the Option context with map. The functions stay exactly as they are. The pipeline now survives a missing input automatically: feed it a Some, it flows through and produces a Some; feed it a None, it short-circuits through every step untouched. You added "handles absence" to your entire pipeline without changing a single one of the underlying functions. That is the power of the Functor: it adds a capability (absence-handling, error-handling, many-ness) to ordinary functions from the outside, by wrapping the context, not by editing the logic.

Going Deeper: Endofunctors and Two-Hole Containers

A couple of notes for the readers who want the edges.

In functional programming, a Functor maps the category of types-and-functions back to itself, types in, types out, functions in, functions out. A Functor whose source and target categories are the same is called an Endofunctor, and technically all the Functors we use are endofunctors. The name shows up when you read the literature; do not let it spook you, it just means "maps a category to itself."

Also, Option and Array each wrap a single type. But Either wraps two, an error type and a value type. So which one does map touch? The convention is that map works on the last type parameter, the Right/success side, and leaves the Left alone, which is why error-handling came free above. A structure that can map over both of its type parameters has its own name, a Bifunctor, and it gives you a mapLeft to transform the error side too. There are further variations (covariant versus contravariant Functors) that decide which direction the mapping flows, but those are a deeper rabbit hole than this series needs. The headline holds: a Functor is a mappable container, and map is how you work inside it.

But Everything Has a Cost

The honest caveats.

You cannot fully generalize Functor in TypeScript today. We wrote a separate mapOption, mapEither, and used the built-in array map, but we could not write one single map that works for any Functor F. TypeScript lacks the feature that would let us abstract over type constructors like that. That is the entire subject of the next part, and it is why libraries like fp-ts exist and look the way they do.

The laws are your responsibility. As with Monoids, the compiler trusts you. A custom Functor with a law-breaking map will quietly produce wrong results. Test your instances.

Wrapping has ergonomic cost. Living inside Option/Either/Array contexts means a lot of map-ing, and the syntax can get noisy before you learn the supporting operations (chain, fold, and friends). The cleanliness is real, but there is a learning ramp.

The Honest Conclusion

.map() was never about arrays. It is the Functor operation, the lawful way to apply an ordinary function inside a context without ever unwrapping it by hand. Option, Either, Array, Promise, all of them are Functors, all of them have a map, and all of them follow the same intuition: reach into the structure, apply the function, preserve the structure. That preservation is the whole point, it is the stick-figure-of-a-human idea made into code.

The practical reward is enormous. The boilerplate absence-check we wrote by hand for Option lives inside map now, written once. Pure functions compose through contexts they were never written to know about, just by lifting them. You add absence-handling or error-handling to an entire pipeline from the outside, without touching the logic inside.

.map() was always about working inside a box without opening it. Learn to see the box, and every wrapped value becomes something you can transform without ever unwrapping it.

Next up: We hit the wall TypeScript puts in front of us. We have Functors for Option, Either, and Array, but no way to write one map for all of them. Next we meet type classes and higher-kinded types, learn exactly why TypeScript fights us here, and see how fp-ts wins anyway.