JavaScript and TypeScript course Β· Module 12: Functional Programming
FP Patterns: Functors and Monads
In this lesson4
Finding a dinosaur's zone takes three steps: find the dinosaur, take its zoneId, find the zone. Each step can return undefined, and one missed check ends in the middle of the night shift with TypeError: Cannot read properties of undefined. Code full of ifs protects against that, but it hides what we are actually doing.
In the advanced laboratory of Jurassic Park, we don't always know whether a DNA sample exists, whether the analysis succeeded, or whether the data is complete. Instead of checking conditions at every step (if/else), we can use elegant patterns from functional programming - functors and monads. They are like intelligent sample containers that know how to handle missing or damaged data on their own.
Functors - Containers with Mapping
A functor is an object (container) that implements a map method. It allows applying transformations to the value inside the container without having to extract it. You already know one functor: the array, whose map transforms the contents and returns a new array. The simplest functor of your own is a box for a single value:
1// The simplest functor - Box (container)
2class Box {
3 constructor(value) {
4 this.value = value;
5 }
6
7 map(fn) {
8 return new Box(fn(this.value));
9 }
10
11 toString() {
12 return 'Box(' + this.value + ')';
13 }
14}map does not change this.value, it only creates a new Box with the result of the function - the same immutability principle you know from the previous lesson. Because every map returns a box, the calls chain together:
1// Usage - chain of transformations
2const result = new Box(8000)
3 .map((weight) => weight / 1000) // Box(8)
4 .map((tons) => tons + ' tons') // Box('8 tons')
5 .map((label) => 'Weight: ' + label); // Box('Weight: 8 tons')
6
7console.log(result.value); // 'Weight: 8 tons'Each step receives the unwrapped value, and the result goes back into the box. Only at the very end do we reach for result.value.
Maybe - Handling Missing Values
The Maybe monad solves the problem of null/undefined values. Instead of checking if (x !== null) everywhere, we wrap the value in a Maybe and let the monad handle missing data. The static method of creates the container, isNothing checks whether it is empty, map works as in Box but skips an empty container, flatMap takes a function that returns another Maybe, and getOrElse extracts the value or returns a default:
1class Maybe {
2 constructor(value) {
3 this.value = value;
4 }
5
6 static of(value) {
7 return new Maybe(value);
8 }
9
10 isNothing() {
11 return this.value === null || this.value === undefined;
12 }
13
14 map(fn) {
15 return this.isNothing() ? this : Maybe.of(fn(this.value));
16 }
17
18 flatMap(fn) {
19 return this.isNothing() ? this : fn(this.value);
20 }
21
22 getOrElse(defaultValue) {
23 return this.isNothing() ? defaultValue : this.value;
24 }
25}All the safety sits in a single condition: an empty Maybe answers every map with itself, so the function never receives undefined. Now compare the zone lookup without Maybe and with it:
1// Without Maybe - full of ifs
2function getDinoZoneOld(park, dinoId) {
3 const dino = park.dinosaurs.find((d) => d.id === dinoId);
4 if (!dino) return 'Unknown';
5 const zone = park.zones.find((z) => z.id === dino.zoneId);
6 if (!zone) return 'Unknown';
7 return zone.name;
8}
9
10// With Maybe - elegant chain
11function getDinoZone(park, dinoId) {
12 return Maybe.of(park.dinosaurs.find((d) => d.id === dinoId))
13 .map((dino) => dino.zoneId)
14 .flatMap((zoneId) => Maybe.of(park.zones.find((z) => z.id === zoneId)))
15 .map((zone) => zone.name)
16 .getOrElse('Unknown');
17}The ifs are gone, and the "not found" case is handled by a single getOrElse at the end. For the zone we use flatMap, because the function in that step returns a Maybe itself - a plain map would give a Maybe inside a Maybe. The order is always the same: wrap with Maybe.of, transform with map, join steps that return a Maybe with flatMap, and extract the result with getOrElse. The shortest chain is Maybe.of(data).map((d) => d.name).getOrElse('Unknown').
You know the same pair from arrays. flatMap maps first and then flattens the result by one level:
1const zoneIds = ['A', 'B'];
2const dinosByZone = { A: ['Rex', 'Blue'], B: ['Brachio'] };
3
4zoneIds.map((id) => dinosByZone[id]); // [['Rex', 'Blue'], ['Brachio']]
5zoneIds.flatMap((id) => dinosByZone[id]); // ['Rex', 'Blue', 'Brachio']map returns an array of arrays, and flatMap a single flat list. Maybe.flatMap does the same with boxes. For simply reading fields, the built-in dino?.zone ?? 'Unknown' is enough, and Maybe pays off when the steps are whole lookups.
Either - Error Handling
Maybe only says that a value is missing, not why. Either represents a value that can be a success (Right) or a failure (Left). It's a functional alternative to try/catch:
1class Left {
2 constructor(value) { this.value = value; }
3 map(fn) { return this; } // Ignores transformations
4 flatMap(fn) { return this; }
5 getOrElse(defaultValue) { return defaultValue; }
6 isLeft() { return true; }
7 isRight() { return false; }
8}
9
10class Right {
11 constructor(value) { this.value = value; }
12 map(fn) { return new Right(fn(this.value)); }
13 flatMap(fn) { return fn(this.value); }
14 getOrElse(defaultValue) { return this.value; }
15 isLeft() { return false; }
16 isRight() { return true; }
17}Both classes have the same API, but Right.map applies the function, while Left.map skips it and carries the error on. After the first failure, the rest of the chain does not run. Let's see this in sample validation:
1// Usage in DNA sample validation
2function validateSample(sample) {
3 if (!sample.id) return new Left('Missing sample ID');
4 if (sample.purity < 0.5) return new Left('Purity too low: ' + sample.purity);
5 if (!sample.species) return new Left('Unknown species');
6 return new Right(sample);
7}
8
9function processSample(sample) {
10 return validateSample(sample)
11 .map((s) => ({ ...s, processed: true }))
12 .map((s) => ({ ...s, label: s.species + '-' + s.id }));
13}
14
15// Success
16const good = processSample({ id: 'S001', purity: 0.95, species: 'T-Rex' });
17good.getOrElse('Error'); // { id: 'S001', purity: 0.95, species: 'T-Rex', processed: true, label: 'T-Rex-S001' }
18
19// Failure
20const bad = processSample({ id: 'S002', purity: 0.3, species: 'Raptor' });
21bad.getOrElse('Error'); // 'Error' (error: 'Purity too low: 0.3')Sample S001 goes through both transformations. For S002 both map calls are skipped, but the message is not lost: it sits in bad.value, and bad.isLeft() returns true. Either does not catch exceptions on its own, though, so keep try/catch at the boundary with code that throws, such as JSON.parse.
How It All Fits Together
In FP jargon, a monad is a functor that also has flatMap (often called chain in libraries) and a way of putting a value into the container, such as of. The pillars of this module build up from the ground: pure functions, immutability, higher-order functions, composition with currying, and functors and monads at the top. You refactor imperative code in the same order: you find the side effects, extract pure functions, replace loops with map, filter and reduce, connect the steps with pipe, and wrap the risky places in Maybe or Either. A data pipeline has similar layers: parsing, validation with Maybe or Either, transformation with map, aggregation with reduce and a report - you will build one in the final project. Do not wrap everything in containers, though: they pay off at the boundary with data from the outside.
A functor, and the monad built on top of it, is simply a container that already knows what to do when the sample inside is missing or contaminated, which is how Maybe and Either carry a whole chain of transformations through to the end instead of scattering your code with null checks and try/catch blocks.
Code for this lesson: index.js
1// FP patterns: functors and monads
2console.log("=== Jurassic Park - Smart DNA Containers ===\n");
3
4// --- EXAMPLE 1: The Box functor ---
5console.log("--- Box functor ---");
6
7class Box {
8 constructor(value) { this.value = value; }
9 map(fn) { return new Box(fn(this.value)); }
10 toString() { return "Box(" + JSON.stringify(this.value) + ")"; }
11}
12
13const result = new Box(8000)
14 .map((w) => w / 1000)
15 .map((t) => t + " tons")
16 .map((l) => "Weight: " + l);
17
18console.log(result.toString()); // Box("Weight: 8 tons")
19
20// --- EXAMPLE 2: Maybe ---
21console.log("\n--- The Maybe monad ---");
22
23class Maybe {
24 constructor(value) { this.value = value; }
25 static of(v) { return new Maybe(v); }
26 isNothing() { return this.value === null || this.value === undefined; }
27 map(fn) { return this.isNothing() ? this : Maybe.of(fn(this.value)); }
28 flatMap(fn) { return this.isNothing() ? this : fn(this.value); }
29 getOrElse(def) { return this.isNothing() ? def : this.value; }
30 toString() { return this.isNothing() ? "Maybe(Nothing)" : "Maybe(" + JSON.stringify(this.value) + ")"; }
31}
32
33const park = {
34 dinosaurs: [
35 { id: "D01", name: "Rex", zoneId: "Z01" },
36 { id: "D02", name: "Blue", zoneId: "Z02" },
37 ],
38 zones: [
39 { id: "Z01", name: "Zone A" },
40 { id: "Z02", name: "Zone B" },
41 ],
42};
43
44function getDinoZone(parkData, dinoId) {
45 return Maybe.of(parkData.dinosaurs.find((d) => d.id === dinoId))
46 .map((dino) => dino.zoneId)
47 .flatMap((zoneId) => Maybe.of(parkData.zones.find((z) => z.id === zoneId)))
48 .map((zone) => zone.name)
49 .getOrElse("Unknown");
50}
51
52console.log("Rex zone:", getDinoZone(park, "D01")); // Zone A
53console.log("Blue zone:", getDinoZone(park, "D02")); // Zone B
54console.log("Unknown:", getDinoZone(park, "D99")); // Unknown
55
56// --- EXAMPLE 3: Either ---
57console.log("\n--- The Either monad ---");
58
59class Left {
60 constructor(value) { this.value = value; }
61 map(fn) { return this; }
62 flatMap(fn) { return this; }
63 getOrElse(def) { return def; }
64 isLeft() { return true; }
65 isRight() { return false; }
66 toString() { return "Left(" + this.value + ")"; }
67}
68
69class Right {
70 constructor(value) { this.value = value; }
71 map(fn) { return new Right(fn(this.value)); }
72 flatMap(fn) { return fn(this.value); }
73 getOrElse(def) { return this.value; }
74 isLeft() { return false; }
75 isRight() { return true; }
76 toString() { return "Right(" + JSON.stringify(this.value) + ")"; }
77}
78
79function validateSample(sample) {
80 if (!sample.id) return new Left("Missing sample ID");
81 if (sample.purity < 0.5) return new Left("Purity too low: " + sample.purity);
82 if (!sample.species) return new Left("Unknown species");
83 return new Right(sample);
84}
85
86function processSample(sample) {
87 return validateSample(sample)
88 .map((s) => ({ ...s, processed: true }))
89 .map((s) => ({ ...s, label: s.species + "-" + s.id }));
90}
91
92const good = processSample({ id: "S01", purity: 0.95, species: "T-Rex" });
93console.log("Success:", good.toString());
94console.log("Value:", JSON.stringify(good.getOrElse("ERROR")));
95
96const bad1 = processSample({ id: "", purity: 0.95, species: "T-Rex" });
97console.log("\nFail 1:", bad1.toString());
98
99const bad2 = processSample({ id: "S02", purity: 0.3, species: "Raptor" });
100console.log("Fail 2:", bad2.toString());
101
102// --- EXAMPLE 4: An Either chain ---
103console.log("\n--- Either chain ---");
104
105function parseInput(raw) {
106 try { return new Right(JSON.parse(raw)); }
107 catch (e) { return new Left("Parse error: " + e.message); }
108}
109
110const pipeline = (raw) =>
111 parseInput(raw)
112 .flatMap((data) => data.name ? new Right(data) : new Left("Missing name"))
113 .map((data) => ({ ...data, status: "validated" }));
114
115console.log(pipeline('{"name":"Rex"}').toString());
116console.log(pipeline('{"age":5}').toString());
117console.log(pipeline('invalid json').toString());Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What must an object implement to be a functor?
2. What does Array.flatMap() return compared to Array.map()?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Vertical ordering
Arrange the steps for safely retrieving data using Maybe:
- Horizontal ordering
Arrange the elements of a flatMap call on Maybe:
- Horizontal ordering
Arrange the elements of a Maybe chain for safely extracting data:
- Click in order
Arrange the elements of validation with Either:
- Vertical ordering
Arrange the pillars of functional programming from most fundamental:
- Code editor
Implement safe data lookup and validation using Maybe and Either.
- Vertical ordering
Arrange the elements of a memoize implementation with closure:
- Click in order
Arrange the elements of array grouping with reduce:
- Vertical ordering
Arrange the stages of building a functional data processing pipeline:
- Horizontal ordering
Arrange the elements of a partial application implementation:
- Click in order
Arrange the elements of a filter-map-reduce pipeline:
- Vertical ordering
Arrange the stages of refactoring from imperative to functional code:
- Vertical ordering
Arrange the layers of a functional data processing pipeline from input to output: