DEV Community

Cover image for What monads really are (and why you've been using them all along)
John Munn
John Munn

Posted on

What monads really are (and why you've been using them all along)

If you’ve ever chained .then() calls, mapped over an array, or used async/await, congratulations, you’ve already used a monad. You just didn’t call it that.

Most explanations start with abstract math: endofunctors, morphisms, category theory. Let’s skip that.

A monad is a pattern for sequencing transformations safely, a way to handle “and then…” without breaking everything when something goes wrong.

With tools like effect-ts gaining traction and Rust patterns bleeding into JS, understanding monads is becoming less academic and more practical.


The everyday monad: promises

Let’s start with something you already know.

const getUser = (id) =>
  fetch(`/api/users/${id}`).then(res => res.json());

getUser("123")
  .then(user => fetch(`/api/orders/${user.id}`))
  .then(res => res.json())
  .then(console.log);
Enter fullscreen mode Exit fullscreen mode

Each .then() takes the output of the previous step, keeps it wrapped in a Promise, and passes it forward. That’s a monad in the wild. A container that lets you chain work without tearing it open each time.


Why we bother

Without monads, you’d constantly be doing this:

getUser("123")
  .then(userPromise =>
    userPromise
      ? // What if user is null?
        fetch(`/api/orders/${userPromise.id}`).then(res => res.json())
      : Promise.reject("no user") // What if address is missing?
  );
Enter fullscreen mode Exit fullscreen mode

That’s messy and fragile. Monads abstract the wrapping and unwrapping so you can focus on the logic, not the plumbing.

Here’s a clearer before-and-after view:

// Nested promises
fetch(url)
  .then(r => r.json())
  .then(data => data?.user?.address ? data.user.address : null);

// Monadic chain (conceptually)
fetch(url)
  .then(r => r.json())
  .then(Maybe.of)
  .flatMap(u => Maybe.of(u.user))
  .flatMap(u => Maybe.of(u.address));
Enter fullscreen mode Exit fullscreen mode

The key insight: flatMap lets you chain functions that return wrapped values, while map is for functions that return plain ones.


Build one: the "Maybe" monad

Sometimes you get data that might be null or undefined. Instead of endless if checks, we’ll make a simple Maybe wrapper.

In plain JavaScript:

const Some = (value) => ({ kind: "some", value });
const None = () => ({ kind: "none" });

const map = (m, fn) =>
  m.kind === "some" ? Some(fn(m.value)) : None();

const flatMap = (m, fn) =>
  m.kind === "some" ? fn(m.value) : None();
Enter fullscreen mode Exit fullscreen mode

Usage:

const safeDivide = (a, b) => (b === 0 ? None() : Some(a / b));

const result = flatMap(safeDivide(10, 2), x => safeDivide(x, 5));
console.log(result); // { kind: "some", value: 1 }
Enter fullscreen mode Exit fullscreen mode

Add TypeScript for safety

Once this pattern clicks, TypeScript can enforce these contracts at compile time instead of runtime.

type Maybe<T> = { kind: "some"; value: T } | { kind: "none" };

const Some = <T>(value: T): Maybe<T> => ({ kind: "some", value });
const None = <T>(): Maybe<T> => ({ kind: "none" });

const flatMap = <T, U>(m: Maybe<T>, fn: (v: T) => Maybe<U>): Maybe<U> =>
  m.kind === "some" ? fn(m.value) : None();
Enter fullscreen mode Exit fullscreen mode

TypeScript now stops you from mapping the wrong function or unwrapping a None() by accident.


Why this matters in real projects

You already use monads every day:

  • Promise<T> for async results
  • Array<T> for multiple results
  • Option/Maybe<T> for optional values
  • Result<T, E> (inspired by Rust) for success or failure

They give you consistency, the same predictable way to chain transformations without blowing up your code.

Real-world example:* parsing a nested API response safely.

const getCity = (res: any): Maybe<string> =>
  res && res.user && res.user.address ? Some(res.user.address.city) : None();

Some(response)
  .flatMap(r => getCity(r))
  .map(city => city.toUpperCase());

// Returns None() and short-circuits safely
Some(null).flatMap(r => getCity(r)).map(city => city.toUpperCase());
Enter fullscreen mode Exit fullscreen mode

When simple is better

Sometimes you don’t need monads at all. If a null check does the job, do that. Use monads when data needs to flow through several uncertain steps or when you’re composing transformations across async boundaries.


When to reach for them

  • Error handling: Replace scattered try/catch with a Result monad
  • Optional data: Use Maybe instead of if (x) checks
  • Async logic: You’re already doing it with Promise
  • Complex data flows: Compose transformations safely instead of nesting callbacks

Takeaways

  • A monad is just a wrapper + a way to chain (flatMap)
  • You use them already: promises, arrays, optionals
  • TypeScript helps you express them safely, but you can learn the idea in JS first
  • Once you start seeing them, you’ll notice where they simplify your code, and where they’re overkill

The next time you write if (x && x.y && x.y.z), ask yourself. Am I just building a monad by hand?


What's next

Explore these for deeper dives:

Top comments (0)