cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Transducers, functors & monads

Learn transducers, functors, Maybe, Either, and Observables in plain JavaScript, with runnable examples that show when each idea helps.

By the end, you can
  • 01
    Fuse array stepsTrace intermediate arrays, then combine map and filter into one reduce pass with transducers.
  • 02
    Read container APIsUse map, chain, and flatMap on Box, Maybe, Either, Array, Promise, and Observables.
  • 03
    Choose the simple toolDecide when optional chaining, exceptions, promises, events, or RxJS are clearer than a custom abstraction.

Plain words first

This lesson uses a simple rule: do not learn the word first. Watch the working JavaScript, then name the pattern. The ideas sound academic, but they answer everyday questions: can we avoid extra arrays, keep null checks tidy, return validation errors without throwing, and model a stream of events?

Definition

A transducer fuses transformations into one reduce pass. A functor is a container you can map over. A monad is a container with chain or flatMap so a callback can return another container without nesting. An Observable is a lazy stream that can send many values and be unsubscribed.

Real-life analogyPackages moving along one conveyor

Think about a warehouse line. You can move every package into a bin after each worker, or you can arrange the workers along one conveyor. The package still changes, but it does not need a new bin after every step.

In real life: A worker stamps each package
In JavaScript: map transforms each value
In real life: A gate lets only heavy packages through
In JavaScript: filter decides whether the reducer runs
In real life: One conveyor means no temporary bins
In JavaScript: A transducer fuses steps into one pass
In real life: A sealed package still has a label
In JavaScript: A container such as Box, Maybe, or Result keeps a value plus rules

Where the analogy stops: A real conveyor always moves physical boxes. JavaScript containers are just objects or arrays with methods, and a Promise or Observable can involve time, errors, and cancellation.

The vocabulary in one place
WordPlain meaningWhen to care
TransducerA function that changes a reducer so map/filter-style work can be fused into one pass.Use for large pipelines when intermediate arrays matter.
FunctorA container with a lawful map method.Arrays and a tiny Box are beginner-friendly examples.
MaybeA container that is either Just(value) or Nothing.Use to stop null-safe chains without throwing.
Either / ResultA container that is either Ok(value) or Err(message).Use to return validation errors without exceptions.
MonadA container with chain or flatMap so functions that already return containers do not nest.Arrays, Promises, Maybe, and Either all have a version of this idea.
ObservableA lazy stream that can send many values over time and be unsubscribed.Useful for events, sockets, timers, and RxJS-style data flows.

You will see the same old tools from map, filter & reduce, pure functions, optional chaining, promise chaining, and events. Composition & pipelines is being built in this same module, so we will point to that upcoming idea without depending on its route yet.

Transducers & fusion: avoid the hidden arrays

STEP THROUGH

Start with familiar code. Each native map returns a new array. Each native filter returns a new array. The counters show how many callbacks ran; the logged doubled and large values make the intermediate arrays visible.

Chained array methods make intermediate arraysPop out in the code editor (opens in a new tab)JavaScript
const numbers = [1, 2, 3, 4, 5]; let mapCalls = 0;let filterCalls = 0;let labelCalls = 0; const doubled = numbers.map((number) => {  mapCalls += 1;  return number * 2;}); const large = doubled.filter((number) => {  filterCalls += 1;  return number > 5;}); const labels = large.map((number) => {  labelCalls += 1;  return "#" + number;}); console.log(labels.join(", "));console.log("intermediate arrays:", doubled.join(" "), "|", large.join(" "));console.log("callback calls:", mapCalls + filterCalls + labelCalls);

That style is readable and usually the right first choice. When a pipeline is huge or repeated in a hot path, you can fuse the steps. A transducer is just a function that takes a reducer and returns a new reducer. Here mapping(fn) and filtering(test) are reducer transformers.

Transducer fusion lab
Step 0 of 24Ready
Your turn: follow the blue line

Step through the fused version. Each original number visits reduce once, then mapping, filtering, and appending happen inside that one pass.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
 const mapping = (project) => (reducer) => (list, value) =>  reducer(list, project(value));const filtering = (keep) => (reducer) => (list, value) =>  keep(value) ? reducer(list, value) : list;const compose = (...fns) => (value) =>  fns.reduceRight((next, fn) => fn(next), value); const xform = compose(  mapping((number) => number * 2),  filtering((number) => number > 5),  mapping((number) => "#" + number),);let visits = 0;const append = (list, value) => {  list.push(value);  return list;};const reducer = xform(append);const result = numbers.reduce((list, value) => {  visits += 1;  return reducer(list, value);}, []);console.log(result.join(", "));console.log("reduce visits:", visits);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Choose the input numbers

Changing the list starts a fresh replay. Watch `visits`: it counts original items, not intermediate arrays.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Fusion is not a moral victory over arrays; it is a trade-off. Use it when the saved work is worth the new vocabulary. Modern JavaScript also has iterator helpers arriving as a built-in lazy alternative, with shapes like Iterator.from(values).map(...).filter(...). Check your target browsers before relying on them in production.

Functors: a container you can map over

REAL CHECKS

You already know one functor: Array. [1, 2, 3].map(fn) changes each value but keeps the Array shape. A tiny Box makes the same idea small enough to inspect. The two laws are not mystical; they protect refactors.

Array and Box obey the functor lawsPop out in the code editor (opens in a new tab)JavaScript
const Box = (value) => ({  map(fn) {    return Box(fn(value));  },  valueOf() {    return value;  },  toString() {    return `Box(${value})`;  },}); const sameBox = (left, right) => Object.is(left.valueOf(), right.valueOf());const id = (value) => value;const compose = (after, before) => (value) => after(before(value));const double = (number) => number * 2;const increment = (number) => number + 1; console.log([1, 2, 3].map(id).join(","));console.log(sameBox(Box(7).map(id), Box(7)));console.log(sameBox(  Box(7).map(compose(double, increment)),  Box(7).map(increment).map(double),));
  1. Identity law: mapping (value) => value should not change the container. The Array and Box checks both keep the same value.
  2. Composition law: mapping a composed function once should match mapping the first function and then the second function. The last true proves the two Box paths agree.
Why laws matter

Laws are regression tests for mental models. If map secretly drops values, mutates the container, or calls the function twice, ordinary refactors stop being safe.

Maybe & Either: values that may not be there

NULL SAFE

A Maybe has two cases: Just(value) and Nothing. The code below treats null and undefined as Nothing. Its map method runs only while a real value is present.

Maybe lab: where does the chain stop?
Step 0 of 5Ready
Your turn: follow the blue line

Choose a user key, then step through a null-safe chain. The important part is where map stops running.

Running in
  1. script
Next: line 22
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
const Maybe = {  fromNullable(value) {    return value == null ? Nothing() : Just(value);  },};function Just(value) {  return {    map(fn) { return Maybe.fromNullable(fn(value)); },    chain(fn) { return fn(value); },    withDefault() { return value; },    toString() { return `Just(${value})`; },  };}function Nothing() {  return {    map() { return Nothing(); },    chain() { return Nothing(); },    withDefault(fallback) { return fallback; },    toString() { return "Nothing"; },  };}  ada: { profile: { email: "ada@example.com" } },  lin: {},};const key = "lin";const label = Maybe.fromNullable(users[key])  .map((user) => user.profile)  .map((profile) => profile.email)  .withDefault("No email");console.log(label);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Choose the lookup key

`ada` has an email, `lin` has no profile, and `missing` has no user at all.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Be honest about plain JavaScript. For one property path, optional chaining is simpler:

Optional chaining is often enoughPop out in the code editor (opens in a new tab)JavaScript
const user = {};console.log(user.profile?.email ?? "No email");

Maybe becomes more interesting when you want one shared map/chain shape across many small functions. Either, often called Result, is similar but stores a useful error message instead of only “nothing.”

Result validation without exceptionsPop out in the code editor (opens in a new tab)JavaScript
const Ok = (value) => ({  map(fn) { return Ok(fn(value)); },  chain(fn) { return fn(value); },  match(arms) { return arms.ok(value); },});const Err = (message) => ({  map() { return Err(message); },  chain() { return Err(message); },  match(arms) { return arms.err(message); },}); function validateSignup(form) {  if (!form.email.includes("@")) return Err("Email needs @");  if (form.password.length < 8) return Err("Password too short");  return Ok({ email: form.email.toLowerCase() });} const good = validateSignup({ email: "ADA@EXAMPLE.COM", password: "correct horse" });const bad = validateSignup({ email: "ada", password: "short" }); console.log(good.map((data) => data.email).match({  ok: (email) => "ok: " + email,  err: (message) => "error: " + message,}));console.log(bad.map((data) => data.email).match({  ok: (email) => "ok: " + email,  err: (message) => "error: " + message,}));

Exceptions are still right for truly exceptional failures. Result is handy when validation failure is expected UI data: show the message, keep the page alive, and avoid wrapping every normal branch in try/catch.

Monads: containers with chain or flatMap

PLAIN WORDS

A monad is not “a scary functional thing.” In this lesson, it means: a container has a method for callbacks that already return the same kind of container. Without it, you get nested containers. With it, the next step receives the plain value when the container is in the success case.

  • Maybe.chain(fn) runs fn(value) for Just and skips it for Nothing.
  • Result.chain(fn) runs the next validation for Ok and keeps the first Err.
  • Array.prototype.flatMap maps each item to an array and flattens one level.
  • Promise.prototype.then acts like chain for async steps that return promises.
Promise flattens thenablesPop out in the code editor (opens in a new tab)JavaScript
const thenable = {  then(resolve) {    resolve("flattened by Promise");  },}; Promise.resolve(thenable).then((value) => {  console.log(value);});

That Promise example is also the honest caveat. Promises auto-flatten any object with a callable then method, called a thenable. That practical behavior is useful on the web, but it means Promise does not behave like a perfectly lawful mathematical monad in every edge case.

Observables: many values over time

STREAMS

A Promise answers once. An Observable can call next many times, then complete, and a subscriber can unsubscribe. The implementation below is intentionally tiny, but it has the moving parts you need to read RxJS-style code: subscribe, next, complete, unsubscribe, map, and filter.

A tiny Observable with map and filterPop out in the code editor (opens in a new tab)JavaScript
class TinyObservable {  constructor(start) {    this.start = start;  }  subscribe(observer) {    let closed = false;    let cleanup = () => {};    const safe = {      next(value) {        if (!closed) observer.next?.(value);      },      complete() {        if (!closed) {          closed = true;          observer.complete?.();          cleanup();        }      },    };    cleanup = this.start(safe) || (() => {});    if (closed) cleanup();    return {      unsubscribe() {        if (!closed) {          closed = true;          cleanup();        }      },    };  }  map(project) {    return new TinyObservable((observer) => {      const subscription = this.subscribe({        next: (value) => observer.next(project(value)),        complete: () => observer.complete(),      });      return () => subscription.unsubscribe();    });  }  filter(keep) {    return new TinyObservable((observer) => {      const subscription = this.subscribe({        next: (value) => {          if (keep(value)) observer.next(value);        },        complete: () => observer.complete(),      });      return () => subscription.unsubscribe();    });  }}TinyObservable.of = (...values) => new TinyObservable((observer) => {  values.forEach((value) => observer.next(value));  observer.complete();}); const numbers = TinyObservable.of(1, 2, 3, 4);numbers  .map((number) => number * 2)  .filter((number) => number > 4)  .subscribe({    next(value) { console.log(value); },    complete() { console.log("done"); },  });
Observable filter lab
Observable pipelinePop out in the code editor (opens in a new tab)JavaScript
const numbers = TinyObservable.of(1, 2, 3, 4, 5); numbers  .map((number) => number * 2)  .filter((number) => number >= 6)  .subscribe({    next(value) { console.log(value); },    complete() { console.log("done"); },  });
Console outputthreshold 6
  1. 6
  2. 8
  3. 10
  4. done
Try it yourself
Keep doubled values at least:

The Observable starts when subscribed, doubles each value, keeps values >= 6, then completes.

The source uses the tiny Observable class from the code block above as setup, so Edit & run behaves like the lesson.
Promise, Observable, and EventTarget compared
QuestionPromiseObservableEventTarget
ShapePromiseObservableEventTarget
How many values?One fulfillment or rejectionZero, one, or many next values, then completeMany events of named types
When does it start?Usually eager: executor runs immediatelyLazy: the start function runs on subscribeListener waits after addEventListener
CancellationNo standard cancel for a plain Promiseunsubscribe() runs cleanupremoveEventListener or AbortController
Best fitFetch one resource or sequence async stepsTimers, UI events, sockets, async streamsSimple DOM events

Simple DOM events are still often clearest with addEventListener and custom events. Observables help when you want a stream pipeline: map, filter, merge, debounce, retry, and unsubscribe in one vocabulary. RxJS is the mature library many teams use for that.

Native Observable status

A web search of WICG and browser support data for September 2026 shows the native Observable proposal, including EventTarget.prototype.when, shipped in Chromium-family browsers such as Chrome and Edge 135+; this machine’s Chrome 154 should have it. Firefox and Safari do not support it yet, so feature-detect or use RxJS when you need broad support.

Feature-detect EventTarget.whenJavaScript
const button = document.querySelector("button"); if (button && typeof button.when === "function") {  const subscription = button    .when("click")    .map((event) => event.type)    .subscribe({ next: (type) => console.log(type) });   subscription.unsubscribe();} else {  console.log("EventTarget.when is not available in this browser.");}

Where these ideas show up in real code

SORT IT

Advanced functional vocabulary is useful only when it makes code easier to change. Use the simplest thing that keeps the behavior honest. Optional chaining, Result validation, Promise chaining, EventTarget, RxJS, and transducers can all be the right answer in different parts of a frontend.

Choose the smallest useful abstraction
SituationGood first toolWhy
One nullable property pathUse plain JavaScriptuser.profile?.email ?? "No email" is shorter and clearer than teaching a team Maybe.
Many reusable validation stepsUse Result / EitherEach step can return Ok or Err, and chain stops at the first error.
Huge map/filter/reduce pipelineConsider transducers or lazinessFusing avoids intermediate arrays, but measure before adding abstraction.
DOM click stream with cancellationEventTarget, Observable, or RxJSUse addEventListener for simple cases; use an Observable when map/filter/cancel pipelines help.
One async valuePromisePromises are built in and familiar; Observables are for many values over time.
Name the pattern
  • items.map(format) keeps the Array shape.
  • Box(4).map(x => x) should behave like Box(4).
  • Maybe.fromNullable(user).chain(loadProfile) avoids Just(Just(...)).
  • sentences.flatMap(sentence => sentence.split(' ')) flattens one level.
  • mapping(fn)(reducer) returns a new reducer.
  • filtering(keep)(append) skips values before append sees them.
  • stream.subscribe({ next(value) { ... } }) handles future values.
  • subscription.unsubscribe() stops the producer cleanup.
Try it yourself
0 of 8 correct

Sort each snippet by the idea it demonstrates. Some cards mention JavaScript built-ins; focus on the behavior.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Composition & pipelines, being built alongside this lesson, is where these small pieces become larger flows. The important habit is the same: keep each step pure when you can, and isolate effects at the boundary.

Common misconceptions

  • “A monad is a special class.” In JavaScript it is usually a convention: a value with chain, flatMap, or then-like behavior.
  • “Maybe is always better than ?..” For one property path, optional chaining and ?? are clearer. Maybe helps when many functions share one null-safe shape.
  • “Either means no exceptions ever.” Use Result for expected business failures. Throw for bugs, broken invariants, and unexpected infrastructure failures.
  • “Transducers make code faster automatically.” They remove intermediate arrays, but the extra function calls and readability cost still need measurement.
  • “Observables replace promises.” Promises are the built-in tool for one async value. Observables are for many values, laziness, and cancellation.
  • “Functor laws are academic trivia.” The laws explain why refactoring box.map(f).map(g) into box.map((x) => g(f(x))) should be safe.
Similar containers, different jobs
QuestionMaybeEither / ResultObservable
What can go wrong?The value may be missing.The operation may fail with a message.Values arrive over time or may be cancelled.
How do I transform success?map on Just; skip Nothing.map on Ok; skip Err.map every next value before completion.
How do I continue with another container?chainchainoperators such as map, filter, and higher-order stream operators

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict the fused pipeline’s ordinary version

Read the normal array chain and type the exact text printed.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const numbers = [1, 2, 3, 4, 5];
const labels = numbers
  .map((number) => number * 2)
  .filter((number) => number > 5)
  .map((number) => "#" + number);
console.log(labels.join(", "));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeMap over Box twice

    Predict the number printed by the two Box maps.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const Box = (value) => ({
      map(fn) { return Box(fn(value)); },
      valueOf() { return value; },
    });
    const result = Box(4).map((number) => number + 1).map((number) => number * 2);
    console.log(result.valueOf());

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind the null bug

      The starter code throws. Fix it with plain JavaScript and type the text the fixed version prints.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const user = {};
      console.log(user.profile.email);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeReturn a validation error without throwing

        Predict the Result validation output.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const Ok = (value) => ({
          chain(fn) { return fn(value); },
          match(arms) { return arms.ok(value); },
        });
        const Err = (message) => ({
          chain() { return Err(message); },
          match(arms) { return arms.err(message); },
        });
        const minLength = (password) => password.length >= 8 ? Ok(password) : Err("Password too short");
        console.log(minLength("abc").match({
          ok: () => "ok",
          err: (message) => "error: " + message,
        }));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeApply the idea to a real site

          A search box emits many keystrokes. You want to filter empty strings, wait before sending a request, and cancel everything when the page changes. Which abstraction from this lesson best fits?

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            7 QUESTIONS
            Lesson quiz · 7 questionsScore: first tries count
            1. Question 1 of 7What problem does a transducer solve in this lesson?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does the functor identity check print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const Box = (value) => ({
                map(fn) { return Box(fn(value)); },
                valueOf() { return value; },
              });
              const id = (value) => value;
              console.log(Box(4).map(id).valueOf());

              Choose an answer to see the explanation.

            3. Question 3 of 7What does the optional chaining comparison print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const user = {};
              console.log(user.profile?.email ?? "No email");

              Choose an answer to see the explanation.

            4. Question 4 of 7What does mapping over an Err do?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const Err = (message) => ({
                map() { return Err(message); },
                match(arms) { return arms.err(message); },
              });
              console.log(Err("bad email").map((value) => value.toUpperCase()).match({
                ok: (value) => value,
                err: (message) => message,
              }));

              Choose an answer to see the explanation.

            5. Question 5 of 7In this lesson, what makes a container monad-like?

              Choose an answer to see the explanation.

            6. Question 6 of 7What does the thenable example print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const thenable = {
                then(resolve) {
                  resolve("flattened by Promise");
                },
              };
              Promise.resolve(thenable).then((value) => console.log(value));

              Choose an answer to see the explanation.

            7. Question 7 of 7Which statement about Observables is true?

              Choose an answer to see the explanation.

            Key takeaways

            • Transducers fuse reducer-based transformations so one input value flows through all steps in one pass.
            • A functor is a container with a lawful map; Array and Box make the laws visible.
            • Maybe handles missing values, while Either or Result carries expected error messages.
            • A monad-like container has chain, flatMap, or then for callbacks that return containers.
            • Observables model many lazy, cancellable values; promises model one eventual value.

            Remember the one-liner.
            These patterns are containers and pipelines with rules: map when you stay inside, chain when the next step already returns a container, and keep plain JavaScript when it is clearer.

            Up next: the Regular expressions module begins with Patterns & flags.

            CompleteFrontend Clear concepts. Working examples.