Transducers, functors & monads
Learn transducers, functors, Maybe, Either, and Observables in plain JavaScript, with runnable examples that show when each idea helps.
- 01Join array stepsSee the extra arrays that
mapandfiltermake, then join the steps into onereducepass with a transducer. - 02Use container methodsUse
map,chain, andflatMapon Box, Maybe, Either, arrays, promises, and Observables. - 03Pick the simple toolDecide when
?.,throw, promises, events, or RxJS are clearer than a container you write yourself.
Plain words first
This lesson has big names: transducer, functor, monad, Observable. Don't learn the names first. Look at the working JavaScript first, then learn the name for it.
Each idea answers a normal question you meet in real code:
- How can I run
mapandfilterwithout making extra arrays? - How can I read a value that might be missing, without many
ifchecks? - How can a form check return an error message without throwing an error?
- How can I handle many values that arrive over time, like clicks?
Here are the four words. A container is a value that holds another value inside it, like an array.
- A transducer joins several steps, like
mapandfilter, into one pass over the list. - A functor is a container with a
mapmethod.mapchanges the value inside and keeps the container. - A monad is a container with a
chainorflatMapmethod. Use it when your function already returns a container, so you don't get a container inside a container. - An Observable sends many values over time. It starts only when someone listens, and the listener can stop it.
Think of a warehouse. Workers stamp packages, and a gate lets only some through. One way is to put every package in a bin after each worker. The next worker then empties the bin.
A better way is one conveyor belt that goes past all the workers. Each package still gets every step. But no bins are needed between the workers.
- In real life: A worker stamps each package
- In JavaScript:
mapchanges each value - In real life: A gate lets only heavy packages through
- In JavaScript:
filterdecides which values go on - In real life: One conveyor belt, so no bins between workers
- In JavaScript: A transducer does all steps in one pass
- In real life: A sealed package still has a label on it
- In JavaScript: A container such as Box, Maybe, or Result holds a value and has rules for it
This table lists every new word in this lesson. Read across a row to see what the word means and when it helps.
| Word | What it means | When it helps |
|---|---|---|
| Transducer | A function that takes a reducer and returns a new reducer with one more step, so map and filter steps run in one pass. | A very long list, where the extra arrays cost too much. |
| Functor | A container with a map method that follows two rules. | Every time you use an array's map. A tiny Box shows the idea. |
| Maybe | A container that is either Just(value) or Nothing. | Reading a value that might be missing, without throwing an error. |
| Either / Result | A container that is either Ok(value) or Err(message). | Returning a form error message instead of throwing. |
| Monad | A container with chain or flatMap, so a function that returns a container doesn't make a container inside a container. | Arrays, promises, Maybe, and Either all have a method like this. |
| Observable | A stream that sends many values over time, starts only when someone listens, and can be stopped. | Clicks, timers, live server messages, and RxJS code. |
You will use tools from earlier lessons: map, filter & reduce, pure functions, optional chaining, promise chaining, and events. The transducer code also uses a compose helper from Composition & pipelines.
Transducers: map and filter without extra arrays
STEP THROUGHStart with code you know. map always returns a new array. filter also returns a new array. So a chain of three steps makes an array after each step.
The code below gives those arrays names, doubled and large, so you can see them. It also counts how many times each function runs.
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);- Line 7 doubles every number. The new array
doubledis[2, 4, 6, 8, 10]. - Line 12 keeps numbers above 5. The new array
largeis[6, 8, 10]. - Line 17 turns each kept number into a label like
"#6". - Line 22 prints
#6, #8, #10. Line 23 prints the two arrays made on the way.
This chain is easy to read, and it is usually the right choice. But with a very long list, the extra arrays cost time and memory. Then you can join the steps into one pass.
A reducer is the function you give to reduce. It takes the result so far and one value, and returns the new result. A transducer is a function that takes a reducer and returns a new reducer with one more step built in.
mapping(fn)makes a reducer that changes each value withfnfirst.filtering(test)makes a reducer that skips a value whentestsays no.composejoins them, so one reducer does all three steps.
doubled and large. Bottom: one reducer takes each number through all three steps, so only the result array is made.Step through the joined version. Each number visits reduce once. Doubling, filtering, labeling, and adding all happen in that one visit.
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);Joining the steps is a trade-off, not always a win. It saves the extra arrays, but the code is harder to read. Use it when the saved work matters.
Newer JavaScript also has iterator helpers. They give you map and filter that work one value at a time, with no extra arrays. They look like Iterator.from(values).map(...).filter(...). Check that the browsers you support have them before you use them.
Functors: a container you can map over
REAL CHECKSYou already know one functor: the array. [1, 2, 3].map(fn) changes each value, and the result is still an array.
The code below makes a tiny container called Box. It holds one value. Its map method runs a function on that value and puts the answer in a new Box.
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),));map takes the 7 out, runs increment on it, and puts the 8 in a new Box. The value changes, but it is still a Box.A good map follows two rules. People call them the functor laws. The last three console.log lines check them.
- The identity rule: mapping
(value) => valuechanges nothing. The function gives back what it gets, so the container stays the same. Lines 19 and 20 check this for the array and for Box. - The composition rule: one
mapwith two functions joined gives the same result as twomapcalls, one function each. Lines 21 to 24 check this. Both ways give 16, so it printstrue.
Here is the array again, with a real task. Its values change, but the result is still an array.
const prices = [3, 5];const withTax = prices.map((price) => (price * 1.1).toFixed(2));console.log(withTax.join(", "));- Line 1 makes an array with two prices.
- Line 2 adds 10% tax to each price.
toFixed(2)turns it into text with two decimals. This also hides tiny rounding errors, like3.3000000000000003. - Line 3 prints
3.30, 5.50. The result is still an array, now of text.
The rules let you change code safely. You can join two map calls into one, or split one into two, and the result stays the same. If a map drops values, changes the old container, or runs the function twice, that is no longer safe.
Maybe and Either: values that might not be there
NULL SAFEA Maybe is a container that is one of two things:
Just(value): a real value is inside.Nothing: the value is missing.
In the code below, Maybe.fromNullable turns null and undefined into Nothing. Any other value becomes a Just. The map method runs your function only on a Just. On a Nothing, it skips the function.
Pick a user key, then step through the chain. Watch for the step where map stops running its function.
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);lin has no profile, so the first map gives Nothing. The next map is skipped, and withDefault gives "No email".For one property path, plain JavaScript is simpler. ?. stops when a value is missing, and ?? gives a fallback value:
const user = {};console.log(user.profile?.email ?? "No email");Maybe helps more when many small functions all return a value that might be missing. Then they can all share the same map and chain methods.
Either, often called Result, is like Maybe, but the "missing" side keeps an error message. It is one of two things:
Ok(value): the step worked, and here is the value.Err(message): the step failed, and here is why.
The match method takes two functions, ok and err. It runs ok for an Ok and err for an Err.
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,}));- Lines 1 and 6 make
OkandErr.Err'smapskips the function and keeps the message. - Lines 13 and 14 return an
Errfor a bad email or a short password. Line 15 returns anOk. - Line 21 prints
ok: ada@example.com. Line 25 printserror: Email needs @.
Result also keeps a longer check easy to read. chain passes the value to the next check only when the step before gave an Ok. An Err skips the next check and keeps its first message.
const parseAge = (text) => { const age = Number(text); return Number.isInteger(age) ? Ok(age) : Err("Age must be a number");};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 adult = (age) => age >= 18 ? Ok("allowed") : Err("Must be 18 or older");console.log(parseAge("20").chain(adult).match({ ok: (message) => message, err: (message) => message,}));parseAge("20")givesOk(20), because 20 is a whole number.- Line 6:
Ok'schainpasses 20 toadult. Line 10:Err'schainwould skip it. adult(20)givesOk("allowed"), so the code printsallowed.
Errors with throw are still right for real surprises, like a bug. Result is useful when a failure is normal, like a wrong form field. You show the message, the page keeps working, and you don't need try/catch around every check.
Monads: containers with chain or flatMap
PLAIN WORDSIn this lesson, a monad is a container with one more method. That method is for functions that already return the same kind of container.
If you use map with such a function, you get a container inside a container. The extra method takes the value out first, so the next step gets a plain value. Here is that method in four containers:
Maybe.chain(fn)runsfn(value)forJustand skips it forNothing.Result.chain(fn)runs the next check forOkand keeps the firstErr.Array.prototype.flatMapmaps each item to an array, then removes one level of nesting.Promise.prototype.thendoes the same for steps that wait and return a promise.
A thenable is any object with a then method. A promise treats a thenable like a promise. It calls the then method and takes the value out.
const thenable = { then(resolve) { resolve("flattened by Promise"); },}; Promise.resolve(thenable).then((value) => { console.log(value);});- Line 1 makes a plain object with a
thenmethod. - Line 7 gives it to
Promise.resolve. The promise callsthen, gets the text, and logsflattened by Promise. You never see the object itself.
A promise chain also carries errors. If a then step throws an error, the promise is rejected, which means it failed. The next catch gets the error. So success and failure stay in one chain, with no nested try blocks.
Promise.resolve("Asha") .then((user) => user + " loaded") .then(() => { throw new Error("offline"); }) .catch((error) => console.log("error: " + error.message));- Line 2 turns
"Asha"into"Asha loaded". - Line 3 throws an error with the message
offline. - Line 4 catches it and prints
error: offline.
Arrays have the same idea for work that does not wait. map would give one inner array per basket. flatMap maps and then removes that one level.
const baskets = [["tea", "bread"], ["rice"]];const items = baskets.flatMap((basket) => basket);console.log(items.join(", "));(basket) => basket. map keeps the arrays inside the array; flatMap removes one level.One warning about promises. A promise takes the value out of any thenable, as you saw above. This helps on the web, where many libraries make their own thenables. But it means a promise does not follow the strict monad rules in every case.
Observables: many values over time
STREAMSA promise gives one answer. An Observable can give many. It works with these methods:
subscribe: start listening. This is what starts the Observable.next: send one value to the listener. It can run many times.complete: say "no more values".unsubscribe: stop listening and clean up.mapandfilter: change or skip values on the way.
The code below builds a tiny Observable class with only these parts. It is small on purpose. Once you can read it, you can read code that uses RxJS, a popular library for Observables.
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"); }, });- Line 5,
subscribe, runs the start function. Line 9,next, passes each value on until the stream is closed. - Line 12,
complete, closes the stream. Line 23,unsubscribe, closes it and runs the clean-up. - Lines 31 and 40 add
mapandfilter. Each one makes a new Observable around the old one. - Lines 59 to 61 double 1, 2, 3, 4, keep values above 4, and print
6,8, thendone.
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 on subscribe. It doubles each number, keeps values of 6 or more, then prints done.
An Observable is lazy. That means making it does not start it. It starts at subscribe. Then unsubscribe runs its clean-up function. That makes it easy to stop timers, close connections, and clean up when a part of the page goes away.
let starts = 0;const stream = new TinyObservable((observer) => { starts += 1; observer.next("tea"); return () => console.log("cleaned up");});console.log("before:", starts);const subscription = stream.subscribe({ next: console.log });console.log("after:", starts);subscription.unsubscribe();- Line 2 makes the stream. Its start function does not run yet.
- Line 7 prints
before: 0, so nothing has started. - Line 8 subscribes. The start function runs and sends
tea. - Line 9 prints
after: 1. Line 10 unsubscribes, which printscleaned up.
This table compares a promise, an Observable, and an EventTarget (any object you can add an event listener to, like a button). Read down a column for one tool.
| Question | Promise | Observable | EventTarget |
|---|---|---|---|
| How many values? | One value, or one error. | Zero, one, or many next values, then complete. | Many events, each with a type name like "click". |
| When does it start? | Right away. The function you pass to new Promise runs at once. | Only on subscribe. That is when the start function runs. | The listener waits for events after addEventListener. |
| How do I stop it? | A plain promise has no built-in way to stop. | unsubscribe() stops it and runs the clean-up. | Use removeEventListener or an AbortController. |
| What is it best for? | Getting one thing, like one fetch, or steps that run one after another. | Timers, page events, live server messages, and other streams. | Simple page events. |
For simple page events, addEventListener and custom events are often the clearest. Observables help when you want a chain of steps on a stream: map, filter, wait before sending, try again, and stop, all with the same methods. Many teams use RxJS for this.
Some browsers now have Observables built in. The method EventTarget.prototype.when turns events into an Observable. Chrome and Edge have it from version 135. Firefox and Safari don't have it yet. So check that it exists before you use it, or use RxJS.
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.");}- Line 3 checks that the button has a
whenmethod. - Line 5 turns the button's clicks into an Observable.
- Line 11 runs in browsers without
when, and says so.
Where these ideas show up in real code
SORT ITThese ideas are useful only when they make code easier to change. Pick the simplest tool that does the job. Each tool in this lesson is the right answer somewhere in a web app.
This table goes from a situation to a tool. Find your situation in the first column, then read across.
| Your situation | Try this first | Why |
|---|---|---|
| You read one property that might be missing. | Plain JavaScript | user.profile?.email ?? "No email" is shorter and clearer than a Maybe. |
| You have many form checks that you reuse. | Result (Either) | Each check returns Ok or Err, and chain stops at the first error. |
| You run map, filter, and reduce on a huge list. | A transducer or iterator helpers | They skip the extra arrays. But measure first, because they add code. |
| You handle many clicks and must stop listening later. | EventTarget, Observable, or RxJS | Use addEventListener for simple cases. Use an Observable when you want map, filter, and stop in one chain. |
| You wait for one value, like one server reply. | Promise | Promises are built in and well known. Observables are for many values over time. |
Start with code a teammate can read quickly. A custom Maybe helps when a whole project uses it. For one missing field, it is only extra code.
const user = {};const email = user.profile?.email ?? "No email";console.log(email);items.map(format)changes each item, and the result is still an array.Box(4).map(x => x)should act just likeBox(4).Maybe.fromNullable(user).chain(loadProfile)avoidsJust(Just(...)).sentences.flatMap(sentence => sentence.split(' '))removes one level of arrays.mapping(fn)(reducer)returns a new reducer.filtering(keep)(append)skips values beforeappendsees them.stream.subscribe({ next(value) { ... } })handles values that come later.subscription.unsubscribe()stops the stream and runs its clean-up.
Put each piece of code under the idea it shows. Look at what the code does, not at the names it uses.
In Composition & pipelines, you joined small functions into longer chains. The same habit works here. Keep each step pure when you can, and keep side effects, like logging or network calls, at the edges.
Common misconceptions
- "A monad is a special class." In JavaScript, it is just a way of writing a container. The container has
chain,flatMap, or a method that works likethen. - "Maybe is always better than
?.." For one property path,?.and??are clearer. Maybe helps when many functions share the same missing-value checks. - "With Either, you never throw." Use Result for normal failures, like a wrong form field. Still throw for bugs, broken rules in your own code, and surprise failures like a server crash.
- "Transducers always make code faster." They skip the extra arrays. But they add more function calls and are harder to read. Measure before you switch.
- "Observables replace promises." A promise is the built-in tool for one value that comes later. Observables are for many values, starting late, and stopping.
- "The functor rules don't matter in real code." The rules are why you can safely change
box.map(f).map(g)intobox.map((x) => g(f(x))).
This table puts three containers side by side. Read down a column to see what one container is for.
| Question | Maybe | Either / Result | Observable |
|---|---|---|---|
| What can go wrong? | The value may be missing. | The step may fail, with a message. | Values come over time, and the listener may stop. |
| How do I change a good value? | map runs on Just and skips Nothing. | map runs on Ok and skips Err. | map runs on every next value. |
| What if my function returns the same kind of container? | Use chain. | Use chain. | Use stream methods, such as those in RxJS. |
Practice exercises
5 EXERCISESRead the array chain. Type the exact text it prints.
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 doubled values that pass the test are 6, 8, and 10. So the labels are #6, #8, #10.
Predict the number that the two Box maps print.
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() gives back the last number as a plain number.
The starter code throws an error. Fix it with plain JavaScript. Then type the text the fixed code 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. Then ?? gives No email. For one property path, this is simpler than Maybe.
Predict what the Result check prints.
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 runs the err function and prints error: Password too short.
A search box sends a new value on every key press. You want to skip empty text, wait a moment before you send a request, and stop everything when the user leaves the page. Which tool from this lesson fits best?
Use an Observable, or an RxJS stream. It fits many values that you may need to stop, and it has methods like map and filter. A promise is for one value only.
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 a monad?
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 sentence about Observables is true?
Choose an answer to see the explanation.
Key takeaways
- A transducer joins map and filter steps into one reducer, so each value goes through all steps in one pass.
- A functor is a container with a
mapthat follows two rules. Array and Box are examples. - Maybe handles values that might be missing. Either, or Result, keeps an error message for normal failures.
- A monad is a container with
chain,flatMap, orthen, for functions that return a container. - An Observable sends many values, starts on
subscribe, and can be stopped. A promise gives one value later.
Remember the one-liner.
Use map when your function returns a plain value, use chain when it returns a container, and use plain JavaScript when that is clearer.
Up next: the Regular expressions module begins with Patterns & flags.