Functors and Monads in Practice
JavaScript gives you functors and monads everywhere, usually without calling them 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.
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.
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.
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:
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.
// map preserves nesting
[1, 2, 3].map(x => [x * 2]); // [[2], [4], [6]]
// flatMap merges the inner arrays
[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:
- 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
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.
Usage:
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.
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.
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
});
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.
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.
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
Written
- File: sites/jsguides/src/content/tutorials/fp-functors-monads.md
- Words: ~1050
- Read time: 5 min
- Topics covered: functors, monads, Array.map, Array.flatMap, Promise.then, Maybe monad, Either monad, functor laws
- Verified via: MDN JavaScript docs, functional programming research
- Unverified items: none