Kurs JavaScript i TypeScript · Moduł 12: Programowanie funkcyjne
Wzorce FP: funktory i monady
W tej lekcji4
Ustalenie strefy dinozaura to trzy kroki: znajdź dinozaura, weź jego zoneId, znajdź strefę. Każdy krok może zwrócić undefined, a jedno przeoczone sprawdzenie kończy się w środku nocnej zmiany błędem TypeError: Cannot read properties of undefined. Kod pełen ifów chroni przed tym, ale zasłania to, co naprawdę robimy.
W zaawansowanym laboratorium Parku Jurajskiego nie zawsze mamy pewność, czy próbka DNA istnieje, czy analiza się powiodła, czy dane są kompletne. Zamiast sprawdzać warunki na każdym kroku (if/else), możemy użyć eleganckich wzorców z programowania funkcyjnego - funktorów i monad. To jak inteligentne pojemniki na próbki, które same wiedzą, jak obsłużyć brakujące lub uszkodzone dane.
Funktory - pojemniki z mapowaniem
Funktor to obiekt (pojemnik), który implementuje metodę map. Pozwala stosować transformacje do wartości wewnątrz pojemnika, bez konieczności jej wyciągania. Jeden funktor już znasz: tablica, której map przekształca zawartość i zwraca nową tablicę. Najprostszy własny funktor to pudełko na jedną wartość:
1// Najprostszy funktor - Box (pojemnik)
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 nie zmienia this.value, tylko tworzy nowy Box z wynikiem funkcji - to ta sama zasada niemutowalności, którą znasz z poprzedniej lekcji. Ponieważ każde map zwraca pudełko, wywołania łączą się w łańcuch:
1// Użycie - łańcuch transformacji
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'Każdy krok dostaje odpakowaną wartość, a wynik wraca do pudełka. Dopiero na końcu sięgamy po result.value.
Maybe - obsługa brakujących wartości
Monada Maybe rozwiązuje problem wartości null/undefined. Zamiast wszędzie sprawdzać if (x !== null), owijamy wartość w Maybe i pozwalamy monadzie obsługiwać brakujące dane. Statyczna metoda of tworzy pojemnik, isNothing sprawdza, czy jest pusty, map działa jak w Box, ale omija pusty pojemnik, flatMap przyjmuje funkcję zwracającą kolejne Maybe, a getOrElse wyciąga wartość lub zwraca domyślną:
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}Całe bezpieczeństwo siedzi w jednym warunku: pusty Maybe na każde map odpowiada samym sobą, więc funkcja nigdy nie dostanie undefined. Porównaj teraz wyszukiwanie strefy bez Maybe i z nim:
1// Bez Maybe - pełno ifów
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// Z Maybe - elegancki łańcuch
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}Ify zniknęły, a przypadek "nie znaleziono" obsługuje jedno getOrElse na końcu. Przy strefie używamy flatMap, bo funkcja z tego kroku sama zwraca Maybe - zwykłe map dałoby Maybe w Maybe. Porządek jest zawsze ten sam: owiń przez Maybe.of, przekształcaj przez map, kroki zwracające Maybe łącz przez flatMap, a wynik wyciągnij przez getOrElse. Najkrótszy łańcuch to Maybe.of(data).map((d) => d.name).getOrElse('Unknown').
Tę samą parę znasz z tablic. flatMap najpierw mapuje, a potem spłaszcza wynik o jeden poziom:
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 zwraca tablicę tablic, a flatMap jedną płaską listę. Maybe.flatMap robi to samo z pudełkami. Do prostego odczytu pól wystarczy wbudowane dino?.zone ?? 'Unknown', a Maybe opłaca się, gdy kroki to całe wyszukiwania.
Either - obsługa błędów
Maybe mówi tylko, że wartości brak, ale nie mówi dlaczego. Either reprezentuje wartość, która może być sukcesem (Right) lub porażką (Left). To funkcyjna alternatywa dla try/catch:
1class Left {
2 constructor(value) { this.value = value; }
3 map(fn) { return this; } // Ignoruje transformacje
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}Obie klasy mają to samo API, ale Right.map stosuje funkcję, a Left.map ją pomija i niesie błąd dalej. Po pierwszej porażce reszta łańcucha się nie wykonuje. Zobaczmy to przy walidacji próbki:
1// Użycie w walidacji próby DNA
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// Sukces
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// Porażka
20const bad = processSample({ id: 'S002', purity: 0.3, species: 'Raptor' });
21bad.getOrElse('Error'); // 'Error' (błąd: 'Purity too low: 0.3')Próbka S001 przechodzi obie transformacje. Dla S002 oba map są pomijane, ale komunikat nie ginie: leży w bad.value, a bad.isLeft() zwraca true. Either nie łapie jednak wyjątków sam z siebie, więc try/catch zostaw na granicy z kodem, który rzuca, jak JSON.parse.
Jak to wszystko się łączy
W żargonie FP monada to funktor, który ma też flatMap (w bibliotekach często chain) i sposób na włożenie wartości do pojemnika, jak of. Filary tego modułu budują się od podstaw: czyste funkcje, niemutowalność, funkcje wyższego rzędu, kompozycja z curryingiem, a na szczycie funktory i monady. W tej samej kolejności refaktoryzujesz kod imperatywny: znajdujesz efekty uboczne, wydzielasz czyste funkcje, pętle zamieniasz na map, filter i reduce, łączysz kroki przez pipe, a ryzykowne miejsca owijasz w Maybe lub Either. Pipeline danych ma podobne warstwy: parsowanie, walidacja z Maybe lub Either, transformacja przez map, agregacja przez reduce i raport - zbudujesz go w projekcie końcowym. Nie owijaj jednak wszystkiego w pojemniki: opłacają się na granicy z danymi z zewnątrz.
Funktor i monada to po prostu pojemnik na próbkę, który sam wie, co zrobić z brakującą lub uszkodzoną zawartością, dzięki czemu Maybe i Either prowadzą łańcuch transformacji do końca zamiast zasypywać Twój kod dziesiątkami sprawdzeń i bloków try/catch.
Kod do tej lekcji: index.js
1// Wzorce FP: funktory i monady
2console.log("=== Park Jurajski - Inteligentne Pojemniki DNA ===\n");
3
4// --- PRZYKLAD 1: Funktor Box ---
5console.log("--- Funktor Box ---");
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// --- PRZYKLAD 2: Maybe ---
21console.log("\n--- Monada Maybe ---");
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// --- PRZYKLAD 3: Either ---
57console.log("\n--- Monada Either ---");
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// --- PRZYKLAD 4: Lancuch Either ---
103console.log("\n--- Lancuch Either ---");
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());Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Co musi implementować obiekt, aby był funktorem?
2. Co zwraca Array.flatMap() w porównaniu do Array.map()?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w pionie
Ułóż kroki bezpiecznego pobierania danych z użyciem Maybe:
- Układanie w poziomie
Ułóż elementy wywołania flatMap na Maybe:
- Układanie w poziomie
Ułóż elementy łańcucha Maybe do bezpiecznego wyciągania danych:
- Klikanie w kolejności
Ułóż elementy walidacji z Either:
- Układanie w pionie
Ułóż filary programowania funkcyjnego od najbardziej podstawowego:
- Edytor kodu
Zaimplementuj bezpieczne wyszukiwanie i walidację danych z użyciem Maybe i Either.
- Układanie w pionie
Ułóż elementy implementacji memoize z domknięciem:
- Klikanie w kolejności
Ułóż elementy grupowania tablicy z reduce:
- Układanie w pionie
Ułóż etapy budowania funkcyjnego pipeline przetwarzania danych:
- Układanie w poziomie
Ułóż elementy implementacji partial application:
- Klikanie w kolejności
Ułóż elementy pipeline filter-map-reduce:
- Układanie w pionie
Ułóż etapy refaktoringu z kodu imperatywnego na funkcyjny:
- Układanie w pionie
Ułóż warstwy funkcyjnego pipeline przetwarzania danych od wejścia do wyjścia: