JavaScript and TypeScript course Β· Module 12: Functional Programming

Project: Functional Data Pipeline for Jurassic Park

6 min read
In this lesson6

The night shift receives hundreds of sensor readings: motion, fence voltage, temperature. Some of them are damaged, because the identifier is missing or the value is negative, and in the morning the manager wants a single report: which zones are safe and where a team has to be sent. One big script full of loops and flags quickly becomes impossible to maintain.

Time to combine all functional programming techniques into one project. We will build a functional system for processing dinosaur data - from raw sensor readings to complete security reports. Each stage is a pure function, and the entire process is a pipeline composed with pipe and compose.

Project Architecture

Our system consists of several layers:

  1. Parsing - loading raw sensor data
  2. Validation - verifying data correctness (Maybe/Either)
  3. Transformation - processing data (map, filter)
  4. Aggregation - summaries and statistics (reduce)
  5. Reporting - generating the final report

We work on six readings, among which we have deliberately hidden two traps:

1// Input data structure
2const sensorReadings = [
3  { sensorId: 'S01', zone: 'A', type: 'motion', value: 85, timestamp: 1700000000 },
4  { sensorId: 'S02', zone: 'A', type: 'fence', value: 10000, timestamp: 1700000001 },
5  { sensorId: 'S03', zone: 'B', type: 'motion', value: 12, timestamp: 1700000002 },
6  { sensorId: 'S04', zone: 'B', type: 'temperature', value: 38, timestamp: 1700000003 },
7  { sensorId: null, zone: 'C', type: 'motion', value: -5, timestamp: 1700000004 },
8  { sensorId: 'S06', zone: 'A', type: 'fence', value: 0, timestamp: 1700000005 },
9];

The fifth reading has no sensorId and a negative value - that is garbage the validation has to reject. The sixth is a fence at 0 volts: valid data, but alarming. timestamp is Unix time, the number of seconds since January 1, 1970.

Validation Layer with Maybe

The validator returns the reading or null, and the Maybe from the previous lesson turns null into a safe, empty container. A trimmed-down version with map and getOrElse is enough here:

1class Maybe {
2  constructor(value) { this.value = value; }
3  static of(v) { return new Maybe(v); }
4  isNothing() { return this.value === null || this.value === undefined; }
5  map(fn) { return this.isNothing() ? this : Maybe.of(fn(this.value)); }
6  getOrElse(def) { return this.isNothing() ? def : this.value; }
7}
8
9const validateReading = (reading) => {
10  if (!reading.sensorId) return null;
11  if (reading.value < 0) return null;
12  return reading;
13};
14
15const safeValidate = (reading) =>
16  Maybe.of(reading)
17    .map(validateReading)
18    .getOrElse(null);

safeValidate wraps the reading, passes it through the validator and unwraps it right away: a valid reading comes back unchanged, and a damaged one becomes null. Reading S06 passes, because 0 is not a negative value. This is a deliberate simplification, because the later stages work on plain arrays and we will filter out null with a single filter.

Building the Pipeline

First, three small, pure functions for a single reading. Each takes one reading and returns a new object without touching the original:

1// Pure functions - each does one thing
2const isValidReading = (r) => r !== null;
3const enrichWithDate = (r) => ({
4  ...r,
5  date: new Date(r.timestamp * 1000).toISOString(),
6});
7const classifyAlert = (r) => ({
8  ...r,
9  alert: r.type === 'fence' && r.value < 5000 ? 'CRITICAL' :
10         r.type === 'motion' && r.value > 80 ? 'WARNING' : 'OK',
11});

enrichWithDate multiplies the timestamp by 1000, because Date counts in milliseconds, and stores the date in ISO format, e.g. '2023-11-14T22:13:20.000Z'. classifyAlert uses a nested conditional operator: a fence below 5000 V is CRITICAL, motion above 80 is WARNING, and the rest is OK.

The next two functions work on the whole collection. Grouping is the computed-key pattern you know from the lesson on reduce, and we calculate the statistics for each [zone, readings] pair from Object.entries:

1// Aggregation per zone
2const groupByZone = (readings) =>
3  readings.reduce((groups, r) => ({
4    ...groups,
5    [r.zone]: [...(groups[r.zone] || []), r],
6  }), {});
7
8// Statistics per zone
9const calculateZoneStats = (grouped) =>
10  Object.entries(grouped).map(([zone, readings]) => ({
11    zone,
12    totalReadings: readings.length,
13    alerts: readings.filter((r) => r.alert !== 'OK').length,
14    avgValue: readings.reduce((s, r) => s + r.value, 0) / readings.length,
15    status: readings.some((r) => r.alert === 'CRITICAL') ? 'DANGER' : 'SAFE',
16  }));

some checks whether at least one reading in the zone is critical, and then the whole zone gets the DANGER status. A design note: avgValue averages motion, volts and degrees together, which would make no sense in a real report. That is a good place for your first improvement.

Complete Pipeline with pipe

Now we assemble everything into a single production line. The order of the steps in pipe is exactly the order in which they run:

1function pipe(...fns) {
2  return (value) => fns.reduce((acc, fn) => fn(acc), value);
3}
4
5const generateSecurityReport = pipe(
6  // 1. Validate each reading
7  (readings) => readings.map(safeValidate),
8  // 2. Filter out invalid ones
9  (readings) => readings.filter(isValidReading),
10  // 3. Enrich with date
11  (readings) => readings.map(enrichWithDate),
12  // 4. Classify alerts
13  (readings) => readings.map(classifyAlert),
14  // 5. Group per zone
15  groupByZone,
16  // 6. Statistics
17  calculateZoneStats,
18  // 7. Sort - most dangerous first
19  (stats) => [...stats].sort((a, b) => b.alerts - a.alerts),
20);
21
22const report = generateSecurityReport(sensorReadings);

The report has two entries. Zone A: three readings, two alerts (motion 85 and a fence at 0 V), status DANGER. Zone B: two readings, zero alerts, status SAFE. Zone C does not appear at all, because its only reading was dropped at validation - in a real park a silent sector is a separate reason to worry. The last step copies the array before sort, so even the sorting mutates nothing, and sensorReadings looks the same after the whole process as before it.

Extension - Curried Utility Functions

Finally, tools in the curried style. You give the configuration first and the array last, so once configured, every function fits straight into pipe:

1const filterBy = (key) => (value) => (arr) =>
2  arr.filter((item) => item[key] === value);
3
4const mapField = (key) => (fn) => (arr) =>
5  arr.map((item) => ({ ...item, [key]: fn(item[key]) }));
6
7const filterByZone = filterBy('zone');
8const filterZoneA = filterByZone('A');
9
10const normalizeValues = mapField('value')((v) => Math.round(v * 100) / 100);

filterZoneA is a ready-made filter for zone A, and normalizeValues rounds the value field to two decimal places in a copy of each reading. You can insert both functions as further stages of generateSecurityReport.

Your Task for the Mentor

In the editor below you will find a complete, working project to explore and modify. Before you send the link to your mentor, extend it: replace Maybe with Either so that the report lists rejected readings with the reason, calculate averages separately for each sensor type, and flag zones from which no valid reading arrived. Also check that the input stays untouched: freeze sensorReadings and every reading with Object.freeze, and in strict mode every attempt at mutation will throw a TypeError. You will meet the same patterns in World 2: reducers in Redux are pure functions, and state in React is updated with immutable copies.

Remember: a good pipeline is a laboratory line of small, pure workstations - each does one thing, none touches the original sample, and the same data always gives the same report.

Code for this lesson: index.js
1// Project: A functional data pipeline for Jurassic Park
2console.log("=== Jurassic Park - Complete Reporting System ===\n");
3
4// --- The Maybe monad ---
5class Maybe {
6  constructor(value) { this.value = value; }
7  static of(v) { return new Maybe(v); }
8  isNothing() { return this.value === null || this.value === undefined; }
9  map(fn) { return this.isNothing() ? this : Maybe.of(fn(this.value)); }
10  getOrElse(def) { return this.isNothing() ? def : this.value; }
11}
12
13// --- Utility functions ---
14function pipe(...fns) {
15  return (value) => fns.reduce((acc, fn) => fn(acc), value);
16}
17
18function curry(fn) {
19  return function curried(...args) {
20    if (args.length >= fn.length) return fn(...args);
21    return (...more) => curried(...args, ...more);
22  };
23}
24
25const filterBy = curry((field, value, arr) =>
26  arr.filter((item) => item[field] === value)
27);
28
29// --- Input data (sensor readings) ---
30const sensorReadings = [
31  { sensorId: "S01", zone: "A", type: "motion", value: 85, ts: 1700000000 },
32  { sensorId: "S02", zone: "A", type: "fence", value: 10000, ts: 1700000001 },
33  { sensorId: "S03", zone: "B", type: "motion", value: 12, ts: 1700000002 },
34  { sensorId: "S04", zone: "B", type: "temperature", value: 38, ts: 1700000003 },
35  { sensorId: null, zone: "C", type: "motion", value: -5, ts: 1700000004 },
36  { sensorId: "S06", zone: "A", type: "fence", value: 0, ts: 1700000005 },
37  { sensorId: "S07", zone: "C", type: "motion", value: 92, ts: 1700000006 },
38  { sensorId: "S08", zone: "B", type: "fence", value: 8500, ts: 1700000007 },
39];
40
41// --- Validation layer ---
42const validateReading = (reading) =>
43  Maybe.of(reading)
44    .map((r) => r.sensorId ? r : null)
45    .map((r) => r && r.value >= 0 ? r : null)
46    .getOrElse(null);
47
48// --- Pure transformation functions ---
49const isValid = (r) => r !== null;
50
51const enrichWithDate = (r) => ({
52  ...r,
53  date: new Date(r.ts * 1000).toISOString().split("T")[0],
54});
55
56const classifyAlert = (r) => ({
57  ...r,
58  alert: r.type === "fence" && r.value < 5000 ? "CRITICAL"
59       : r.type === "motion" && r.value > 80 ? "WARNING"
60       : "OK",
61});
62
63// --- Aggregation ---
64const groupByZone = (readings) =>
65  readings.reduce((groups, r) => ({
66    ...groups,
67    [r.zone]: [...(groups[r.zone] || []), r],
68  }), {});
69
70const calculateZoneStats = (grouped) =>
71  Object.entries(grouped).map(([zone, readings]) => ({
72    zone,
73    totalReadings: readings.length,
74    criticalAlerts: readings.filter((r) => r.alert === "CRITICAL").length,
75    warnings: readings.filter((r) => r.alert === "WARNING").length,
76    avgValue: Math.round(readings.reduce((s, r) => s + r.value, 0) / readings.length),
77    status: readings.some((r) => r.alert === "CRITICAL") ? "DANGER" : "SAFE",
78  }));
79
80// --- Complete pipeline ---
81const generateSecurityReport = pipe(
82  (readings) => readings.map(validateReading),
83  (readings) => readings.filter(isValid),
84  (readings) => readings.map(enrichWithDate),
85  (readings) => readings.map(classifyAlert),
86  groupByZone,
87  calculateZoneStats,
88  (stats) => [...stats].sort((a, b) => b.criticalAlerts - a.criticalAlerts),
89);
90
91// --- Running it ---
92console.log("Input data:", sensorReadings.length, "readings\n");
93
94const report = generateSecurityReport(sensorReadings);
95
96console.log("=== SECURITY REPORT ===\n");
97report.forEach((zone) => {
98  const icon = zone.status === "DANGER" ? "!!!" : "OK ";
99  console.log(`[${icon}] Zone ${zone.zone}:`);
100  console.log(`    Readings: ${zone.totalReadings}`);
101  console.log(`    Critical: ${zone.criticalAlerts}`);
102  console.log(`    Warnings: ${zone.warnings}`);
103  console.log(`    Average value: ${zone.avgValue}`);
104  console.log(`    Status: ${zone.status}\n`);
105});
106
107// --- Curried report filtering ---
108console.log("=== Filtered reports ===\n");
109
110const filterByStatus = filterBy("status");
111const dangerZones = filterByStatus("DANGER")(report);
112console.log("DANGER zones:", dangerZones.map((z) => z.zone));
113
114const safeZones = filterByStatus("SAFE")(report);
115console.log("SAFE zones:", safeZones.map((z) => z.zone));
116
117// --- Summary ---
118const summary = report.reduce((acc, z) => ({
119  totalReadings: acc.totalReadings + z.totalReadings,
120  totalCritical: acc.totalCritical + z.criticalAlerts,
121  totalWarnings: acc.totalWarnings + z.warnings,
122}), { totalReadings: 0, totalCritical: 0, totalWarnings: 0 });
123
124console.log("\n=== SUMMARY ===");
125console.log("Total readings:", summary.totalReadings);
126console.log("Total critical:", summary.totalCritical);
127console.log("Total warnings:", summary.totalWarnings);

Spotted a mistake in this lesson?

Useful articles