Functors and Monads in JavaScript: From Array.map to Promise Chains
Functors and monads appear throughout JavaScript, usually without being called by name. If you’ve ever chained .map() on an array or used .then() on a Promise, you’ve already used both. This tutorial demystifies these terms by building up from patterns you already know.
What Is a Functor?
A functor is any type that implements a map method. map applies a function to the value inside the container, without unwrapping it.
Arrays are the most familiar example. [1, 2, 3].map(x => x * 2) produces [2, 4, 6]; the array is the container, and map transforms what is inside.
const nums = [1, 2, 3];
const doubled = nums.map(x => x * 2);
// [2, 4, 6]
Promises act similarly. When a Promise resolves, then applies your function to the resolved value:
Promise.resolve(5).then(x => x * 2);
// Promise(10)
Both are functors: containers with a map method that applies a transformation inside. Arrays and Promises use different names for their transformation methods (map vs then), but the pattern is the same — you provide a function and the container applies it to its wrapped value.
The functor laws
A type is only a proper functor if it obeys two laws. JavaScript doesn’t enforce these, but they’re the reason functor composition works.
Identity law: Mapping the identity function returns an equivalent functor. This law ensures that map does not alter the container’s structure when the transformation function does nothing:
const nums = [1, 2, 3];
nums.map(x => x); // [1, 2, 3]; unchanged
Composition law: Mapping a composed function produces the same result as chaining separate maps. This law means you can refactor chains of map calls without changing the result:
const f = x => x * 2;
const g = x => x + 1;
const nums = [1, 2, 3];
nums.map(x => f(g(x))); // [5, 7, 9]
nums.map(g).map(f); // [5, 7, 9]
These laws are what make it safe to refactor arr.map(g).map(f) into arr.map(x => f(g(x))) — or vice versa. When you know a type obeys the functor laws, you can reason about transformations without worrying about hidden surprises.
From functor to monad
Functors are limited: map always preserves the wrapper. If you map over an array with a function that returns an array, you get a nested array.
const nested = [1, 2, 3].map(x => [x, x * 2]);
// [[1, 2], [2, 4], [3, 6]]
Sometimes that’s what you want. Often it isn’t. A monad solves this. A monad is a functor that also implements flatMap (also called chain or bind). flatMap maps and flattens in one step, which means each step in a chain can produce a container without nesting:
const flat = [1, 2, 3].flatMap(x => [x, x * 2]);
// [1, 2, 2, 4, 3, 6]
Think of it as map + flatten. The flattening is the key difference: each step in a monadic chain can produce a container, and the container unwraps automatically. This is what lets you chain operations without manually unwrapping intermediate results.
// map preserves nesting
[1, 2, 3].map(x => [x * 2]); // [[2], [4], [6]]
// flatMap merges the inner arrays automatically
[1, 2, 3].flatMap(x => [x * 2]); // [2, 4, 6]
Promise as a Monad
Promise’s .then() method is the async monad in JavaScript. It behaves as both map and flatMap depending on what you return from the callback:
- When you return a plain value from
.then(), it acts likemap: wrapping the result in a Promise. - When you return a Promise from
.then(), it flattens the nested Promise automatically.
// map behavior: returns plain value, gets wrapped
Promise.resolve(5).then(x => x * 2);
// Promise(10)
// flatMap behavior: returns Promise, which flattens
Promise.resolve(5).then(x => Promise.resolve(x * 2));
// Promise(10), NOT Promise(Promise(10))
This flattening is why you rarely see Promise(Promise(value)) in real code. The monadic behavior of .then() unwraps nested Promises for you.
Promise also obeys monad laws:
// Left identity: Promise.resolve(x).then(f) ≡ f(x)
const f = x => x * 2;
Promise.resolve(5).then(f); // Promise(10)
f(5); // 10
// Right identity: p.then(Promise.resolve) ≡ p
const p = Promise.resolve(5);
p.then(Promise.resolve); // Promise(5)
// Associativity: p.then(f).then(g) ≡ p.then(x => f(x).then(g))
const f = x => Promise.resolve(x * 2);
const g = x => Promise.resolve(x + 1);
Promise.resolve(5).then(f).then(g); // Promise(11)
Promise.resolve(5).then(x => f(x).then(g)); // Promise(11)
These laws are why async/await chains compose predictably, even across large codebases.
A custom Maybe monad
To see how monadic wrapping works under the hood, here is a minimal Maybe implementation. It handles values that may not exist, replacing null checks with chainable operations.
const Some = (value) => ({
map: fn => Some(fn(value)),
flatMap: fn => fn(value),
getOrElse: (defaultVal) => value,
isSome: () => true
});
const None = {
map: fn => None,
flatMap: fn => None,
getOrElse: (defaultVal) => defaultVal,
isSome: () => false
};
None is the short-circuit case. Any map or flatMap call on None just returns None; no computation runs, no error is thrown. This pattern replaces scattered null checks with a consistent chain where the absence of a value propagates automatically.
Here is how you use Maybe to safely drill into a nested object:
const user = { profile: { avatarUrl: '/img/me.png' } };
Some(user)
.map(u => u.profile)
.map(p => p.avatarUrl)
.getOrElse('/img/default.png');
// '/img/me.png'
If user were None, or if user.profile were None, the chain short-circuits and getOrElse fires. This is fail-fast propagation: one None in the chain collapses the whole result. Compare this to the nested if checks you would need without Maybe — each level of nesting requires a separate null guard.
Either for error handling
Maybe works when you only care about presence or absence. When you also need to know why something failed, Either is the better choice.
Either<Error, Value> has two subtypes: Right (success) and Left (failure). By convention, Right holds the happy path value and Left holds the error. The key insight is that map only transforms the Right side — once you’re in a Left, all further map calls are skipped:
const Right = (value) => ({
map: fn => Right(fn(value)),
mapErr: fn => Right(value),
flatMap: fn => fn(value),
getOrElse: () => value,
isRight: () => true
});
const Left = (error) => ({
map: fn => Left(error),
mapErr: fn => Left(fn(error)),
flatMap: fn => Left(error),
getOrElse: (defaultVal) => defaultVal,
isRight: () => false
});
This is the same short-circuit pattern as Maybe but with an error payload attached to the failure branch. When you use Either in a pipeline, each step can see whether it has a Right value or a Left error:
A practical example: parsing JSON where each step can fail:
const parseJSON = (str) => {
try {
return Right(JSON.parse(str));
} catch (e) {
return Left(new Error(`Invalid JSON: ${e.message}`));
}
};
const getName = (obj) =>
Right(obj)
.map(obj => obj.user)
.map(user => user.name)
.mapErr(() => new Error('Could not extract username'));
getName('{"user": {"name": "Ada"}}'); // Right('Ada')
getName('{"user": null}'); // Left(Error('Could not extract username'))
getName('not json'); // Left(Error('Invalid JSON: ...'))
The chain stops at the first Left it encounters. You get error propagation without throwing exceptions.
Why this matters
Monads solve a real problem: chaining operations that produce containers without manual unwrapping. Without them, every step that might fail requires an explicit check:
// Manual null checking — gets unwieldy fast
const getCity = (user) => {
const profile = user.profile;
if (!profile) return null;
const address = profile.address;
if (!address) return null;
return address.city;
};
With monadic chaining:
// Clean chain — each flatMap handles the unwrapping
const getCity = (user) =>
Maybe(user)
.flatMap(u => Maybe(u.profile))
.flatMap(p => Maybe(p.address))
.map(a => a.city)
.getOrElse('Unknown');
The second version is easier to extend and easier to reason about. Each step either produces a value or short-circuits; no mixed control flow. The key benefit is that each flatMap call handles the unwrapping for you, so the chain reads as a sequence of transformations rather than a sequence of null checks. Adding a new step means adding one more flatMap call, not adding another nested if branch.
Conclusion
Functors and monads are not abstract theory. They are practical patterns for managing wrapped values and chained transformations. Arrays and Promises implement both, which means you have been using them all along. Once you recognize the shape — map for functors, flatMap for monads — the abstractions click into place.
Custom monads like Maybe and Either become natural tools when you need explicit handling of absence or failure. They replace scattered null checks and try/catch blocks with declarative chains that either produce a value or propagate the failure.
Laws in Practice
The functor and monad laws sound abstract at first, but they give you a useful way to check whether your helpers behave predictably. If mapping twice changes the meaning of the container, or if chaining produces a surprise shape, the abstraction stops being dependable. That is why small examples matter. They help you see whether the type really supports the style of composition you want, instead of just looking familiar in a quick demo.
Keep composition visible
A long chain of map and flatMap calls should still be readable at a glance. If the chain starts hiding the actual business rule, it may be time to extract a named function or split the transformation into steps. The goal is not to make the code clever. The goal is to keep each step honest about whether it transforms a value, short-circuits a failure, or just forwards the wrapped result to the next stage.
Prefer small custom types
Custom containers are easiest to reason about when they stay small and focused. A Maybe or Either type that tries to solve every problem can become harder to explain than the null checks it replaced. Start with the narrow case that hurts most, then add only the methods that make sense for that case. That keeps the implementation readable and reduces the chance that the type becomes a bag of unrelated helpers.
Use them where they pay off
Functor and monad patterns are strongest when you already have a chain of wrapped values or a steady stream of possible failures. If the code is simple and local, a plain function may be the better choice. The abstraction should remove noise, not add it. That judgment keeps the style useful in real projects rather than turning it into a rule that every function must obey.
See Also
- Currying and Partial Application: how currying enables the point-free style used in monadic chains
- Function Composition: building complex transformations from simple functions
- Async: Callbacks and Promises: Promise as a real-world monad for async operations