Transducers, functors & monads
Learn transducers, functors, Maybe, Either, and Observables in plain JavaScript, with runnable examples that show when each idea helps.
- 01Fuse array stepsTrace intermediate arrays, then combine map and filter into one reduce pass with transducers.
- 02Read container APIsUse
map,chain, andflatMapon Box, Maybe, Either, Array, Promise, and Observables. - 03Choose 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?
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.
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:
maptransforms each value - In real life: A gate lets only heavy packages through
- In JavaScript:
filterdecides 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.
| Word | Plain meaning | When to care |
|---|---|---|
| Transducer | A function that changes a reducer so map/filter-style work can be fused into one pass. | Use for large pipelines when intermediate arrays matter. |
| Functor | A container with a lawful map method. | Arrays and a tiny Box are beginner-friendly examples. |
| Maybe | A container that is either Just(value) or Nothing. | Use to stop null-safe chains without throwing. |
| Either / Result | A container that is either Ok(value) or Err(message). | Use to return validation errors without exceptions. |
| Monad | A 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. |
| Observable | A 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 THROUGHStart 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.
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.
Step through the fused version. Each original number visits reduce once, then mapping, filtering, and appending happen inside that one pass.
script
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);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 CHECKSYou 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.
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),));- Identity law: mapping
(value) => valueshould not change the container. The Array and Box checks both keep the same value. - Composition law: mapping a composed function once should match mapping the first function and then the second function. The last
trueproves the two Box paths agree.
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 SAFEA 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.
Choose a user key, then step through a null-safe chain. The important part is where map stops running.
script
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);Be honest about plain JavaScript. For one property path, optional chaining is simpler:
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.”
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 WORDSA 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)runsfn(value)forJustand skips it forNothing.Result.chain(fn)runs the next validation forOkand keeps the firstErr.Array.prototype.flatMapmaps each item to an array and flattens one level.Promise.prototype.thenacts like chain for async steps that return promises.
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
STREAMSA 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.
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"); }, });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"); }, });6810done
The Observable starts when subscribed, doubles each value, keeps values >= 6, then completes.
| Question | Promise | Observable | EventTarget |
|---|---|---|---|
| Shape | Promise | Observable | EventTarget |
| How many values? | One fulfillment or rejection | Zero, one, or many next values, then complete | Many events of named types |
| When does it start? | Usually eager: executor runs immediately | Lazy: the start function runs on subscribe | Listener waits after addEventListener |
| Cancellation | No standard cancel for a plain Promise | unsubscribe() runs cleanup | removeEventListener or AbortController |
| Best fit | Fetch one resource or sequence async steps | Timers, UI events, sockets, async streams | Simple 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.
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.
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 ITAdvanced 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.
| Situation | Good first tool | Why |
|---|---|---|
| One nullable property path | Use plain JavaScript | user.profile?.email ?? "No email" is shorter and clearer than teaching a team Maybe. |
| Many reusable validation steps | Use Result / Either | Each step can return Ok or Err, and chain stops at the first error. |
| Huge map/filter/reduce pipeline | Consider transducers or laziness | Fusing avoids intermediate arrays, but measure before adding abstraction. |
| DOM click stream with cancellation | EventTarget, Observable, or RxJS | Use addEventListener for simple cases; use an Observable when map/filter/cancel pipelines help. |
| One async value | Promise | Promises are built in and familiar; Observables are for many values over time. |
items.map(format)keeps the Array shape.Box(4).map(x => x)should behave likeBox(4).Maybe.fromNullable(user).chain(loadProfile)avoidsJust(Just(...)).sentences.flatMap(sentence => sentence.split(' '))flattens one level.mapping(fn)(reducer)returns a new reducer.filtering(keep)(append)skips values beforeappendsees them.stream.subscribe({ next(value) { ... } })handles future values.subscription.unsubscribe()stops the producer cleanup.
Sort each snippet by the idea it demonstrates. Some cards mention JavaScript built-ins; focus on the behavior.
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, orthen-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)intobox.map((x) => g(f(x)))should be safe.
| Question | Maybe | Either / Result | Observable |
|---|---|---|---|
| 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? | chain | chain | operators such as map, filter, and higher-order stream operators |
Practice exercises
5 EXERCISESRead the normal array chain and type the exact text printed.
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(", "));The kept doubled values are 6, 8, and 10, so the final labels are #6, #8, #10.
Predict the number printed by the two Box maps.
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());The Box holds 4, then 5, then 10. valueOf() returns the final plain number.
The starter code throws. Fix it with plain JavaScript and type the text the fixed version prints.
const user = {};
console.log(user.profile.email);const user = {};
console.log(user.profile?.email ?? "No email");user.profile?.email stops safely when profile is missing, and ?? prints No email. This is simpler than Maybe for one property path.
Predict the Result validation output.
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,
}));The password check returns Err("Password too short"), so match prints error: Password too short.
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?
Use an Observable or RxJS stream when a widget has many cancellable values and needs operators like map, filter, or debounce. A Promise is for one value.
Check your understanding
7 QUESTIONSQuestion 1 of 7What problem does a transducer solve in this lesson?
Choose an answer to see the explanation.
Question 2 of 7What does the functor identity check print?
Read the code, then predictconst 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.
Question 3 of 7What does the optional chaining comparison print?
Read the code, then predictconst user = {}; console.log(user.profile?.email ?? "No email");Choose an answer to see the explanation.
Question 4 of 7What does mapping over an
Errdo?Read the code, then predictconst 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.
Question 5 of 7In this lesson, what makes a container monad-like?
Choose an answer to see the explanation.
Question 6 of 7What does the thenable example print?
Read the code, then predictconst thenable = { then(resolve) { resolve("flattened by Promise"); }, }; Promise.resolve(thenable).then((value) => console.log(value));Choose an answer to see the explanation.
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, orthenfor 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.