Monads, Functors, and Functional Concepts Simplified

TL;DR: Functors, Applicatives, and Monads are patterns for working with values wrapped in a context (containers) without unwrapping them manually. Youâll learn:
- What a Functor is and why
mapmatters - How Applicatives extend Functors with
ap(â<*>orliftA2) - What Monads add with
flatMap/chain(â>=>) - JavaScript examples:
Array,Promise, and customMaybe - Pitfalls, best practices, and when to use each abstraction
What Is a Functor?
A Functor is any data structure that implements a map method, allowing you to apply a function over wrapped values without extracting them. In JavaScript, Array.prototype.map is the canonical example:
// Array as Functor const numbers = [1, 2, 3] const doubled = numbers.map((x) => x * 2) // [2, 4, 6]
Under the hood, map takes a function f: A â B and transforms Functor<A> into Functor<B>. The key laws for Functors (so your abstractions behave predictably) are:
- Identity:
F.map(x => x)is equivalent toFitself. - Composition:
F.map(x => f(g(x)))is equivalent toF.map(g).map(f).
Any containerâArray, Promise, even a custom Maybeâbecomes a Functor if it defines map following these rules.
Applicatives: Functors with Contextual Function Application
Applicatives build on Functors by letting you apply functions inside a context (wrapped in a Functor) to values inside another context. In Haskell notation, Applicative introduces:
pure: wrap a plain value into the context.<*>(pronounced âapâ): givenF<(A â B)>andF<A>, produceF<B>.
In JavaScript, you can see this pattern with Promise.all + mapping, or by implementing a custom ap on Maybe. For clarity, letâs create a minimal Maybe and show how ap works:
// Minimal Maybe implementation class Maybe { constructor(value) { this.value = value } static of(x) { return new Maybe(x) } // Functor map map(fn) { return this.value == null ? Maybe.of(null) : Maybe.of(fn(this.value)) } // Applicative ap ap(maybeWithFn) { return this.value == null || maybeWithFn.value == null ? Maybe.of(null) : Maybe.of(maybeWithFn.value(this.value)) } } // Usage const justAdd = Maybe.of((x) => x + 1) const justTwo = Maybe.of(2) const result = justTwo.ap(justAdd) // Maybe.of(3) const nothing = Maybe.of(null).ap(justAdd) // Maybe.of(null)
Hereâs how it works:
Maybe.of(x => x + 1)wraps a function in theMaybecontext.justTwo.ap(justAdd)applies that inner function to2without unwrapping explicitly.- If either side is
null, theNothingâlike behavior propagates.
Applicatives let you work with multiple contexts. For example, to add two Maybe numbers:
const add = (a) => (b) => a + b const maybeA = Maybe.of(3) const maybeB = Maybe.of(4) const sum = maybeB.ap(maybeA.map(add)) // Breakdown: // maybeA.map(add) => Maybe.of(a => a + 3) // maybeB.ap(Maybe.of(a => a + 3)) => Maybe.of(4 + 3) == Maybe.of(7)
Note: ap is sometimes called liftA2 if you want to lift a normal binary function to two contexts:
const liftA2 = (fn, fa, fb) => fb.ap(fa.map(fn)) const sum2 = liftA2((x, y) => x + y, maybeA, maybeB) // Maybe.of(7)
Enter Monads: Chaining Contextual Computations
Monads extend Applicatives with a flatMap (or chain, often represented by >>=) method, which flattens nested contexts. In practice, flatMap combines:
- Applying a function that returns a wrapped value:
fn: A â M<B> - Automatically unwrapping one layer so you end up with
M<B>instead ofM<M<B>>.
Why flatMap Matters
Consider Promise: each .then() takes a callback that either returns a value or another Promise. Behind the scenes, Promise implements a Monad:
// Promise as Monad Promise.resolve(2) .then((x) => Promise.resolve(x + 3)) // returns Promise<5> .then((result) => console.log(result)) // 5
Without Monad behavior, youâd end up with nested Promise<Promise<âŚ>>. Thanks to flatMap-like chaining, the intermediate promise is flattened.
Custom Maybe as Monad
Building on our Maybe, letâs add flatMap:
class Maybe { constructor(value) { this.value = value } static of(x) { return new Maybe(x) } map(fn) { return this.value == null ? Maybe.of(null) : Maybe.of(fn(this.value)) } flatMap(fn) { return this.value == null ? Maybe.of(null) : fn(this.value) } } // Example: const safeDivide = (x) => (y) => y === 0 ? Maybe.of(null) : Maybe.of(x / y) Maybe.of(10) .flatMap(safeDivide(2)) // Maybe.of(5) .flatMap(safeDivide(0)) // Maybe.of(null)
Here:
safeDivide(2)yieldsy => (2 / y or null).- First
flatMapunwrapsMaybe.of(10), appliessafeDivide(2)âMaybe.of(5). - Next,
flatMap(safeDivide(0))unwrapsMaybe.of(5), but dividing by 0 yieldsMaybe.of(null)â final result isNothing.
Monad Laws
To keep things predictable, Monads must follow three laws:
- Left Identity:
M.of(x).flatMap(fn)is equivalent tofn(x). - Right Identity:
m.flatMap(M.of)is equivalent tom. - Associativity:
m.flatMap(fn).flatMap(g)is equivalent tom.flatMap(x => fn(x).flatMap(g)).
When these laws hold, you can safely refactor and compose your monadic code.
JavaScript Examples of Functor/Applicative/Monad
1. Array as Functor (and to some extent, Monad)
- Functor:
array.map(fn)appliesfnover each element. - Monad (list monad): Flat-mapping listsâ
flatMap(fn)flattens arrays of arrays.
const nums = [1, 2, 3] const pairs = nums.flatMap((x) => [x, x * 2]) // [1,2, 2,4, 3,6]
2. Promise as Applicative and Monad
- Functor:
.then(result => fn(result))is likemap. - Applicative: You can wrap a function in a
Promiseand doPromise.all. - Monad:
.thenflattens nestedPromise<Promise<âŚ>>.
const fetchUser = (id) => fetch(`/users/${id}`).then((res) => res.json()) const fetchOrders = (user) => fetch(`/orders/${user.id}`).then((res) => res.json()) // Chained computations via flatMap (Promise.then): fetchUser(42) .then(fetchOrders) .then((orders) => console.log(orders)) .catch((err) => console.error(err))
3. Custom Either for Error Handling
Either is like Maybe, but with an error (Left) or success (Right) branch:
class Either { constructor(tag, value) { this.tag = tag this.value = value } static Right(x) { return new Either('Right', x) } static Left(err) { return new Either('Left', err) } map(fn) { return this.tag === 'Right' ? Either.Right(fn(this.value)) : this } flatMap(fn) { return this.tag === 'Right' ? fn(this.value) : this } } // Usage: const parseJSON = (str) => { try { return Either.Right(JSON.parse(str)) } catch (e) { return Either.Left('Invalid JSON') } } parseJSON('{"valid": true}') .flatMap((obj) => Either.Right(obj.valid)) .map((valid) => console.log('Is valid JSON:', valid)) // â Is valid JSON: true parseJSON('invalid') .flatMap((obj) => Either.Right(obj.valid)) .map((valid) => console.log(valid)) // No-op since itâs a Left
Either conforms to Functor and Monad laws. It helps to avoid nested try/catch and manual error checks.
When and Why to Use Each Abstraction
- Functor: Use when you have a container-like structure (e.g., arrays, optional values) and you want to transform the inner value without unwrapping.
- Applicative: Use when you have functions inside a context and you need to apply them to values inside another context. Common with validation pipelines or combining multiple
Maybe/Either. - Monad: Use when each step can fail or produce a new context. Chaining
Promisecalls, safe division withMaybe, or error pipelines withEither.
General Guidelines:
- If you only need to apply a single-argument function over wrapped values,
map(Functor) is enough. - If you need to apply a function of N arguments where each argument is in a context, reach for Applicative (
ap,liftA2, orPromise.all). - If each step returns a new wrapped value (e.g.,
Promise,Maybe, orEither), and you need to chain them, use Monad (flatMap/chain).
Pitfalls & Best Practices
- Breaking Laws: If your
map,ap, orflatMapdonât follow the identity/composition/associativity laws, youâll introduce subtle bugs when composing. - Overusing Abstractions: Keep it simpleâif plain callbacks are clearer, donât force Monads. Sometimes a straightforward
if/elseis more readable than aMaybechain. - Nested Contexts: Be mindful of
F<F<A>>. If you find yourself callingmaptwice, see if you actually needflatMapinstead. - Callback Hell vs. Monad Hell: Monads tame deeply nested callbacks, but deeply nested monadic chains can still be hard to read. Break logic into small, named functions.
- Performance Considerations: Chaining many small functions can incur tiny overhead. In performance-critical loops, collapsing steps or bypassing abstractions may be warranted.
Conclusion
Functors, Applicatives, and Monads provide a principled way to work with values in contextâwhether itâs Array, Promise, Maybe, or Either. By respecting the fundamental laws, you unlock composability and safer pipelines. Start by recognizing existing functors (like Array.map) in your codebase, then experiment with Applicative patterns (ap, liftA2) when combining multiple contexts. Only introduce Monads when you need to chain context-producing functions safely. Over time, these abstractions will feel like natural extensions of your JavaScript toolkit, making code more declarative, predictable, and reusable.
Next Up: Try writing a small Maybe or Either utility in your codebase and refactor a sequence of null-checks or try/catch blocks into monadic chains.