Kurs JavaScript i TypeScript · Moduł 12: Programowanie funkcyjne

Wzorce FP: funktory i monady

6 min czytania
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. 1. Co musi implementować obiekt, aby był funktorem?

  2. 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:

Przydatne artykuły