Michael Ouroumis logoichael Ouroumis

Monads, Functors, and Functional Concepts Simplified

A dark blue circuit-board background with white bold text reading ‘Monads, Functors, and Functional Concepts Simplified’ centered at the top. In the bottom right corner, a bright yellow square contains the black ‘JS’ logo.

TL;DR: Functors, Applicatives, and Monads are patterns for working with values wrapped in a context (containers) without unwrapping them manually. You’ll learn:

  1. What a Functor is and why map matters
  2. How Applicatives extend Functors with ap ( <code><*></code> or liftA2)
  3. What Monads add with flatMap / chain ( <code>>=></code>)
  4. JavaScript examples: Array, Promise, and custom Maybe
  5. 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:

  1. Identity: F.map(x => x) is equivalent to F itself.
  2. Composition: F.map(x => f(g(x))) is equivalent to F.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”): given F<(A → B)> and F<A>, produce F<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:

  1. Maybe.of(x => x + 1) wraps a function in the Maybe context.
  2. justTwo.ap(justAdd) applies that inner function to 2 without unwrapping explicitly.
  3. If either side is null, the Nothing‐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:

  1. Applying a function that returns a wrapped value: fn: A → M<B>
  2. Automatically unwrapping one layer so you end up with M<B> instead of M<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:

  1. safeDivide(2) yields y => (2 / y or null).
  2. First flatMap unwraps Maybe.of(10), applies safeDivide(2)Maybe.of(5).
  3. Next, flatMap(safeDivide(0)) unwraps Maybe.of(5), but dividing by 0 yields Maybe.of(null) → final result is Nothing.

Monad Laws

To keep things predictable, Monads must follow three laws:

  1. Left Identity: M.of(x).flatMap(fn) is equivalent to fn(x).
  2. Right Identity: m.flatMap(M.of) is equivalent to m.
  3. Associativity: m.flatMap(fn).flatMap(g) is equivalent to m.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) applies fn over 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 like map.
  • Applicative: You can wrap a function in a Promise and do Promise.all.
  • Monad: .then flattens nested Promise<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 Promise calls, safe division with Maybe, or error pipelines with Either.

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, or Promise.all).
  • If each step returns a new wrapped value (e.g., Promise, Maybe, or Either), and you need to chain them, use Monad (flatMap/chain).

Pitfalls & Best Practices

  • Breaking Laws: If your map, ap, or flatMap don’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/else is more readable than a Maybe chain.
  • Nested Contexts: Be mindful of F<F<A>>. If you find yourself calling map twice, see if you actually need flatMap instead.
  • 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.

Enjoyed this post? Share it: