JavaScript and TypeScript course Β· Module 9: Design Patterns and Architecture

Functional Programming in JavaScript/TypeScript

23 min read
In this lesson6

Imagine a lab where the same DNA sample gives a different result depending on the time of day, because the analysis function peeks at a global variable. Functional programming cures this with pure functions, immutability and composition, which make code predictable, testable and easier to maintain.

Pure Functions

A pure function meets two conditions:

  1. Determinism - for the same arguments it always returns the same result
  2. No side effects - it does not modify external state and does not perform I/O operations

Examples of impure functions

Each of these functions breaks one of the conditions. Which one?

1// Impure function - depends on external state
2let tax = 0.23;
3
4function calculatePrice(basePrice) {
5  return basePrice * (1 + tax); // Depends on external variable
6}
7
8// Impure function - modifies external state
9let counter = 0;
10
11function incrementCounter() {
12  counter++; // Side effect - modification of global variable
13  return counter;
14}
15
16// Impure function - side effect (I/O)
17function greetUser(name) {
18  console.log(`Hello, ${name}!`); // Side effect - console output
19  return name;
20}
21
22// Impure function - nondeterminism
23function getCurrentTimestamp() {
24  return Date.now(); // Different results for each invocation
25}
26
27// Impure function - modifies argument
28function addItem(array, item) {
29  array.push(item); // Mutates the passed argument
30  return array;
31}

calculatePrice and getCurrentTimestamp break determinism, while the others have side effects: they change a global variable, write to the console or mutate the array they receive.

Examples of pure functions

The pure counterparts receive everything through arguments and return new values:

1// Pure function - determinism and no side effects
2function calculatePrice(basePrice: number, taxRate: number): number {
3  return basePrice * (1 + taxRate);
4}
5
6// Pure function - mathematical operation
7function add(a: number, b: number): number {
8  return a + b;
9}
10
11// Pure function - data transformation
12function formatFullName(firstName: string, lastName: string): string {
13  return `${firstName} ${lastName}`;
14}
15
16// Pure function - working with arrays (no mutation)
17function addItem<T>(array: T[], item: T): T[] {
18  return [...array, item]; // Returns a new array
19}
20
21// Pure function - filtering
22function filterEvenNumbers(numbers: number[]): number[] {
23  return numbers.filter(num => num % 2 === 0);
24}
25
26// Pure function - object transformation
27interface User {
28  id: number;
29  name: string;
30  age: number;
31}
32
33function updateUserAge(user: User, newAge: number): User {
34  return {
35    ...user, // Spread operator - copying existing properties
36    age: newAge // Overriding age
37  };
38}
39
40// Pure function - validation
41function isValidEmail(email: string): boolean {
42  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
43  return emailRegex.test(email);
44}
45
46// Using pure functions
47const products = [
48  { name: 'Laptop', basePrice: 2000 },
49  { name: 'Mouse', basePrice: 50 },
50  { name: 'Keyboard', basePrice: 150 }
51];
52
53const TAX_RATE = 0.23;
54
55const productsWithTax = products.map(product => ({
56  ...product,
57  finalPrice: calculatePrice(product.basePrice, TAX_RATE)
58}));
59
60console.log(productsWithTax);

The tax rate is a parameter, and addItem and updateUserAge return copies, so the originals stay untouched.

Benefits of pure functions

Determinism gives you two superpowers: tests with no world to set up, and memoization, which means remembering results:

1// Easy to test - no external dependencies
2function calculateDiscount(price: number, discountPercent: number): number {
3  return price * (discountPercent / 100);
4}
5
6// Unit test is simple and reliable
7console.assert(calculateDiscount(100, 10) === 10);
8console.assert(calculateDiscount(200, 25) === 50);
9
10// Memoization - optimization thanks to determinism
11function memoize<TArgs extends any[], TReturn>(
12  fn: (...args: TArgs) => TReturn
13): (...args: TArgs) => TReturn {
14  const cache = new Map<string, TReturn>();
15
16  return (...args: TArgs): TReturn => {
17    const key = JSON.stringify(args);
18
19    if (cache.has(key)) {
20      console.log('Cache hit!');
21      return cache.get(key)!;
22    }
23
24    console.log('Computing...');
25    const result = fn(...args);
26    cache.set(key, result);
27    return result;
28  };
29}
30
31// Computationally expensive function
32function fibonacci(n: number): number {
33  if (n <= 1) return n;
34  return fibonacci(n - 1) + fibonacci(n - 2);
35}
36
37// Memoized version
38const memoizedFibonacci = memoize(fibonacci);
39
40console.log(memoizedFibonacci(10)); // Computing... 55
41console.log(memoizedFibonacci(10)); // Cache hit! 55

memoize is a higher-order function: it takes another function as an argument or returns a function, and here it does both. map, filter and the Observer pattern's subscribe are higher-order functions too. The recursive calls inside fibonacci bypass the cache, though, so you only see the gain on a repeated call.

Immutability

Immutability means that objects and data structures are not modified after they are created. Instead, new versions with the changes applied are created, like in an archive where you never overwrite an old sample but add a new one.

Working with primitives

With primitives you get this for free, because strings and numbers in JavaScript are immutable by nature:

1// Primitives in JavaScript are immutable by nature
2let name = 'Jan';
3let newName = name.toUpperCase(); // Creates a new string
4console.log(name);    // 'Jan' - original unchanged
5console.log(newName); // 'JAN' - new value

toUpperCase() does not change the name string, it returns a new one.

Working with arrays

Be careful with arrays, because some methods mutate the original. Compare both groups:

1// Mutable operations (modify the original array)
2const originalArray = [1, 2, 3];
3originalArray.push(4);        // Mutates
4originalArray.pop();          // Mutates
5originalArray.splice(1, 1);   // Mutates
6originalArray.sort();         // Mutates
7
8// Immutable operations (create new arrays)
9const numbers = [1, 2, 3];
10
11// Adding elements
12const withNewElement = [...numbers, 4];
13const withElementAtStart = [0, ...numbers];
14const withElementInMiddle = [...numbers.slice(0, 2), 2.5, ...numbers.slice(2)];
15
16// Removing elements
17const withoutFirst = numbers.slice(1);
18const withoutLast = numbers.slice(0, -1);
19const withoutMiddle = numbers.filter((_, index) => index !== 1);
20
21// Modifying elements
22const doubled = numbers.map(x => x * 2);
23const incremented = numbers.map(x => x + 1);
24
25// Sorting
26const sorted = [...numbers].sort();
27const reverseSorted = [...numbers].sort((a, b) => b - a);
28
29console.log('Original array:', numbers); // [1, 2, 3]
30console.log('Doubled:', doubled);        // [2, 4, 6]
31console.log('Sorted:', sorted);          // [1, 2, 3]
32
33// Advanced immutable operations
34function updateArrayElement<T>(
35  array: T[],
36  index: number,
37  newValue: T
38): T[] {
39  return array.map((item, i) => i === index ? newValue : item);
40}
41
42function moveArrayElement<T>(
43  array: T[],
44  fromIndex: number,
45  toIndex: number
46): T[] {
47  const result = [...array];
48  const [removed] = result.splice(fromIndex, 1);
49  result.splice(toIndex, 0, removed);
50  return result;
51}
52
53const fruits = ['apple', 'banana', 'cherry'];
54const updatedFruits = updateArrayElement(fruits, 1, 'blueberry');
55const movedFruits = moveArrayElement(fruits, 0, 2);
56
57console.log('Original:', fruits);       // ['apple', 'banana', 'cherry']
58console.log('Updated:', updatedFruits); // ['apple', 'blueberry', 'cherry']
59console.log('Moved:', movedFruits);     // ['banana', 'cherry', 'apple']

The rule: push, pop, splice and sort change the array, while spread, slice, map and filter return a new one. Note that sort() without a compare function sorts elements as strings ([10, 9, 1].sort() gives [1, 10, 9]), and since ES2023 you have the non-mutating toSorted(), toReversed(), toSpliced() and with().

Working with objects

We copy objects with the spread operator. With nesting, we copy every level we change:

1// Mutable operations on objects
2const user = { id: 1, name: 'Jan', age: 30 };
3user.age = 31;           // Mutates
4user.email = 'jan@example.com'; // Mutates
5delete user.name;        // Mutates
6
7// Immutable operations on objects
8const originalUser = { id: 1, name: 'Jan', age: 30 };
9
10// Updating properties
11const olderUser = { ...originalUser, age: 31 };
12
13// Adding properties
14const userWithEmail = { ...originalUser, email: 'jan@example.com' };
15
16// Removing properties
17const { name, ...userWithoutName } = originalUser;
18
19// Nested updates
20interface Address {
21  street: string;
22  city: string;
23  country: string;
24}
25
26interface UserWithAddress {
27  id: number;
28  name: string;
29  address: Address;
30}
31
32const userWithAddress: UserWithAddress = {
33  id: 1,
34  name: 'Jan',
35  address: {
36    street: 'Main St',
37    city: 'Warsaw',
38    country: 'Poland'
39  }
40};
41
42// Mutable nested update
43userWithAddress.address.city = 'Krakow'; // Mutates
44
45// Immutable nested update
46const userInKrakow = {
47  ...userWithAddress,
48  address: {
49    ...userWithAddress.address,
50    city: 'Krakow'
51  }
52};
53
54// Helper functions for immutable operations
55function updateProperty<T, K extends keyof T>(
56  obj: T,
57  key: K,
58  value: T[K]
59): T {
60  return { ...obj, [key]: value };
61}
62
63function updateNestedProperty<T>(
64  obj: T,
65  path: string[],
66  value: any
67): T {
68  if (path.length === 1) {
69    return { ...obj, [path[0]]: value };
70  }
71
72  const [firstKey, ...restPath] = path;
73  return {
74    ...obj,
75    [firstKey]: updateNestedProperty(obj[firstKey as keyof T], restPath, value)
76  };
77}
78
79// Using helper functions
80const updatedUser = updateProperty(originalUser, 'age', 35);
81const userInGdansk = updateNestedProperty(
82  userWithAddress,
83  ['address', 'city'],
84  'Gdansk'
85);
86
87console.log('Original:', originalUser);     // { id: 1, name: 'Jan', age: 30 }
88console.log('Updated:', updatedUser); // { id: 1, name: 'Jan', age: 35 }

Spread makes a shallow copy, which is why we unpack address separately. In TypeScript (strict) the lines with user.email and delete user.name will not even compile, because the type has no email field and delete requires an optional field.

Immutability libraries

Copying deep structures by hand gets tiring, which is why Immer exists: you write code that looks like it mutates a draft, and the library returns a new state:

1// Immer - a library that simplifies working with immutable structures
2// npm install immer
3
4import { produce } from 'immer';
5
6interface Todo {
7  id: number;
8  text: string;
9  completed: boolean;
10}
11
12const todos: Todo[] = [
13  { id: 1, text: 'Learn JavaScript', completed: false },
14  { id: 2, text: 'Learn TypeScript', completed: true },
15  { id: 3, text: 'Learn React', completed: false }
16];
17
18// With Immer - looks like mutation, but creates new state
19const updatedTodos = produce(todos, draft => {
20  const todo = draft.find(t => t.id === 1);
21  if (todo) {
22    todo.completed = true;
23  }
24});
25
26const todosWithNewItem = produce(todos, draft => {
27  draft.push({
28    id: 4,
29    text: 'Learn functional programming',
30    completed: false
31  });
32});
33
34console.log('Original todos:', todos);
35console.log('Updated todos:', updatedTodos);
36console.log('Todos with new item:', todosWithNewItem);
37
38// Advanced example with nested structures
39interface Project {
40  id: number;
41  name: string;
42  todos: Todo[];
43  members: string[];
44}
45
46const project: Project = {
47  id: 1,
48  name: 'Project A',
49  todos: todos,
50  members: ['Jan', 'Anna']
51};
52
53const updatedProject = produce(project, draft => {
54  // Add new member
55  draft.members.push('Piotr');
56
57  // Mark all todos as completed
58  draft.todos.forEach(todo => {
59    todo.completed = true;
60  });
61
62  // Change project name
63  draft.name = 'Project A - Completed';
64});
65
66console.log('Original project:', project);
67console.log('Updated project:', updatedProject);

The originals stay untouched, and the unchanged parts are shared. For deeply nested state I recommend Immer.

Currying

Currying is the technique of transforming a function that takes multiple arguments into a series of functions, each taking a single argument.

Basic currying

Here is the same multiplication function in its classic and its curried form:

1// Function with multiple arguments
2function multiply(a: number, b: number, c: number): number {
3  return a * b * c;
4}
5
6console.log(multiply(2, 3, 4)); // 24
7
8// Curried version
9function curriedMultiply(a: number) {
10  return function(b: number) {
11    return function(c: number) {
12      return a * b * c;
13    };
14  };
15}
16
17console.log(curriedMultiply(2)(3)(4)); // 24
18
19// Curried version with arrow functions
20const curriedMultiplyArrow = (a: number) => (b: number) => (c: number) => a * b * c;
21
22console.log(curriedMultiplyArrow(2)(3)(4)); // 24
23
24// Partial application
25const multiplyBy2 = curriedMultiply(2);
26const multiplyBy2And3 = multiplyBy2(3);
27
28console.log(multiplyBy2And3(4)); // 24
29console.log(multiplyBy2And3(5)); // 30

Each call returns another function until the last argument arrives. multiplyBy2 is partial application: it remembers the first argument thanks to a closure.

Practical applications of currying

The real power shows up when the first arguments are configuration and the last one is data:

1// Logging system with currying
2type LogLevel = 'info' | 'warn' | 'error';
3
4const logger = (level: LogLevel) => (message: string) => (context?: object) => {
5  const timestamp = new Date().toISOString();
6  const logEntry = {
7    level,
8    message,
9    timestamp,
10    context
11  };
12
13  console.log(JSON.stringify(logEntry, null, 2));
14};
15
16// Creating specialized loggers
17const infoLogger = logger('info');
18const errorLogger = logger('error');
19const warnLogger = logger('warn');
20
21// Usage
22infoLogger('Application started')();
23errorLogger('Database connection error')({ database: 'PostgreSQL' });
24warnLogger('High RAM usage')({ usage: '85%' });
25
26// Validation system with currying
27type Validator<T> = (value: T) => boolean;
28
29const createValidator = <T>(predicate: (value: T) => boolean) =>
30  (errorMessage: string) =>
31  (value: T): { isValid: boolean; error?: string } => {
32    const isValid = predicate(value);
33    return isValid ? { isValid } : { isValid, error: errorMessage };
34  };
35
36// Validation predicates
37const isNotEmpty = (value: string) => value.trim().length > 0;
38const isEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
39const isMinLength = (minLength: number) => (value: string) => value.length >= minLength;
40const isNumber = (value: string) => !isNaN(Number(value));
41
42// Creating validators
43const requiredValidator = createValidator(isNotEmpty)('Field is required');
44const emailValidator = createValidator(isEmail)('Invalid email format');
45const passwordValidator = createValidator(isMinLength(8))('Password must be at least 8 characters');
46const ageValidator = createValidator(isNumber)('Age must be a number');
47
48// Using validators
49console.log(requiredValidator('Jan'));        // { isValid: true }
50console.log(requiredValidator(''));           // { isValid: false, error: 'Field is required' }
51console.log(emailValidator('jan@example.com')); // { isValid: true }
52console.log(passwordValidator('123'));         // { isValid: false, error: 'Password must be at least 8 characters' }
53
54// API configuration system with currying
55const apiCall = (baseUrl: string) =>
56  (endpoint: string) =>
57  (method: 'GET' | 'POST' | 'PUT' | 'DELETE') =>
58  (data?: any) => {
59    const config = {
60      url: `${baseUrl}${endpoint}`,
61      method,
62      data
63    };
64
65    console.log('API Call:', config);
66    // Here would be the actual API call
67    return Promise.resolve({ success: true, config });
68  };
69
70// Configuration for different environments
71const devApi = apiCall('https://dev-api.example.com');
72const prodApi = apiCall('https://api.example.com');
73
74// Specialized functions for different endpoints
75const usersApi = devApi('/users');
76const productsApi = devApi('/products');
77
78// Specific operations
79const getUsers = usersApi('GET');
80const createUser = usersApi('POST');
81const updateUser = usersApi('PUT');
82
83// Usage
84getUsers();
85createUser({ name: 'Jan', email: 'jan@example.com' });
86updateUser({ id: 1, name: 'Jan Kowalski' });

Each of these functions is a small factory: createValidator produces validators, and apiCall produces API clients. The Factory pattern did the same with a class, for example DinoFactory with a create(type) method returning new Dinosaur(type), separating creation from use.

Automatic currying

The curry helper curries a function automatically, collecting arguments until there are as many as the function has parameters:

1// Helper function for automatic currying
2function curry<TArgs extends any[], TReturn>(
3  fn: (...args: TArgs) => TReturn
4): (...args: any[]) => any {
5  return function curried(...args: any[]) {
6    if (args.length >= fn.length) {
7      return fn(...args as TArgs);
8    } else {
9      return function(...nextArgs: any[]) {
10        return curried(...args, ...nextArgs);
11      };
12    }
13  };
14}
15
16// Example of automatic currying usage
17function sum(a: number, b: number, c: number, d: number): number {
18  return a + b + c + d;
19}
20
21const curriedSum = curry(sum);
22
23// Various ways to call
24console.log(curriedSum(1, 2, 3, 4));     // 10
25console.log(curriedSum(1)(2)(3)(4));     // 10
26console.log(curriedSum(1, 2)(3, 4));     // 10
27console.log(curriedSum(1)(2, 3)(4));     // 10
28
29// Partial application
30const add5 = curriedSum(5);
31const add5And10 = add5(10);
32const add5And10And15 = add5And10(15);
33
34console.log(add5And10And15(20)); // 50
35
36// Example with filtering and sorting
37function filterAndSort<T>(
38  predicate: (item: T) => boolean,
39  compareKey: keyof T,
40  array: T[]
41): T[] {
42  return array
43    .filter(predicate)
44    .sort((a, b) => {
45      if (a[compareKey] < b[compareKey]) return -1;
46      if (a[compareKey] > b[compareKey]) return 1;
47      return 0;
48    });
49}
50
51const curriedFilterAndSort = curry(filterAndSort);
52
53interface Product {
54  id: number;
55  name: string;
56  price: number;
57  category: string;
58  inStock: boolean;
59}
60
61const products: Product[] = [
62  { id: 1, name: 'Laptop', price: 2000, category: 'Electronics', inStock: true },
63  { id: 2, name: 'Mouse', price: 50, category: 'Electronics', inStock: false },
64  { id: 3, name: 'Book', price: 25, category: 'Books', inStock: true },
65  { id: 4, name: 'Keyboard', price: 150, category: 'Electronics', inStock: true }
66];
67
68// Specialized functions
69const filterInStockSortByPrice = curriedFilterAndSort(
70  (product: Product) => product.inStock
71)('price');
72
73const filterElectronicsSortByName = curriedFilterAndSort(
74  (product: Product) => product.category === 'Electronics'
75)('name');
76
77console.log('Available products sorted by price:', filterInStockSortByPrice(products));
78console.log('Electronics sorted by name:', filterElectronicsSortByName(products));

curry relies on fn.length, which does not count a rest parameter or any parameters from the first one with a default value onward.

Function Composition

Function composition is combining simple functions into more complex ones by passing the result of one as the argument of the next, like the stages of a lab line.

Basic composition

Instead of nesting calls, we build a pipeline. compose runs functions from right to left, and pipe from left to right:

1// Simple functions
2const add = (x: number) => x + 1;
3const multiply = (x: number) => x * 2;
4const square = (x: number) => x * x;
5
6// Without composition - nested calls
7const result1 = square(multiply(add(5))); // ((5 + 1) * 2)^2 = 144
8
9// compose function - executes functions from right to left
10function compose<T>(...functions: Array<(arg: T) => T>) {
11  return (value: T): T => {
12    return functions.reduceRight((acc, fn) => fn(acc), value);
13  };
14}
15
16// pipe function - executes functions from left to right (more intuitive)
17function pipe<T>(...functions: Array<(arg: T) => T>) {
18  return (value: T): T => {
19    return functions.reduce((acc, fn) => fn(acc), value);
20  };
21}
22
23// Using compose
24const composedFunction = compose(square, multiply, add);
25console.log(composedFunction(5)); // 144
26
27// Using pipe (more readable)
28const pipedFunction = pipe(add, multiply, square);
29console.log(pipedFunction(5)); // 144
30
31// Practical example - user data processing
32interface User {
33  firstName: string;
34  lastName: string;
35  age: number;
36  email: string;
37}
38
39const users: User[] = [
40  { firstName: 'Jan', lastName: 'Kowalski', age: 25, email: 'jan@example.com' },
41  { firstName: 'Anna', lastName: 'Nowak', age: 30, email: 'anna@example.com' },
42  { firstName: 'Piotr', lastName: 'Wisniewski', age: 22, email: 'piotr@example.com' }
43];
44
45// Helper functions
46const filterAdults = (users: User[]) => users.filter(user => user.age >= 18);
47const sortByAge = (users: User[]) => [...users].sort((a, b) => a.age - b.age);
48const mapToFullNames = (users: User[]) =>
49  users.map(user => `${user.firstName} ${user.lastName}`);
50
51// Composition
52const processUsers = pipe(
53  filterAdults,
54  sortByAge,
55  mapToFullNames
56);
57
58const processedUsers = processUsers(users);
59console.log(processedUsers); // ['Piotr Wisniewski', 'Jan Kowalski', 'Anna Nowak']

Both variants give 144, but pipe reads in the order of execution, and that is the one I recommend. The catch: every step must return the same type, so mapToFullNames will cause a type error in TypeScript, even without strict mode.

Advanced composition with TypeScript

When the type changes along the way, every stage needs its own type parameter, as in compose3:

1// Typed composition for different types
2function compose2<A, B, C>(f: (b: B) => C, g: (a: A) => B) {
3  return (a: A): C => f(g(a));
4}
5
6function compose3<A, B, C, D>(
7  f: (c: C) => D,
8  g: (b: B) => C,
9  h: (a: A) => B
10) {
11  return (a: A): D => f(g(h(a)));
12}
13
14// Example of typed composition usage
15const parseNumber = (str: string): number => parseFloat(str);
16const addTax = (price: number): number => price * 1.23;
17const formatCurrency = (amount: number): string => `${amount.toFixed(2)} PLN`;
18
19const processPrice = compose3(formatCurrency, addTax, parseNumber);
20
21console.log(processPrice('100')); // '123.00 PLN'
22
23// Validation system with composition
24type ValidationResult = {
25  isValid: boolean;
26  errors: string[];
27};
28
29type Validator<T> = (value: T) => ValidationResult;
30
31const combineValidators = <T>(...validators: Validator<T>[]): Validator<T> => {
32  return (value: T): ValidationResult => {
33    const results = validators.map(validator => validator(value));
34
35    return {
36      isValid: results.every(result => result.isValid),
37      errors: results.flatMap(result => result.errors)
38    };
39  };
40};
41
42// Individual validators
43const isRequired: Validator<string> = (value: string) => ({
44  isValid: value.trim().length > 0,
45  errors: value.trim().length === 0 ? ['Field is required'] : []
46});
47
48const isEmail: Validator<string> = (value: string) => {
49  const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
50  return {
51    isValid,
52    errors: isValid ? [] : ['Invalid email format']
53  };
54};
55
56const minLength = (min: number): Validator<string> => (value: string) => {
57  const isValid = value.length >= min;
58  return {
59    isValid,
60    errors: isValid ? [] : [`Minimum ${min} characters required`]
61  };
62};
63
64// Composition of validators
65const emailValidator = combineValidators(isRequired, isEmail);
66const passwordValidator = combineValidators(isRequired, minLength(8));
67
68console.log(emailValidator('jan@example.com')); // { isValid: true, errors: [] }
69console.log(emailValidator(''));                // { isValid: false, errors: ['Field is required', 'Invalid email format'] }
70console.log(passwordValidator('123'));          // { isValid: false, errors: ['Minimum 8 characters required'] }
71
72// Data processing with composition
73interface RawProduct {
74  name: string;
75  price: string;
76  description: string;
77  tags: string;
78}
79
80interface ProcessedProduct {
81  name: string;
82  price: number;
83  description: string;
84  tags: string[];
85  slug: string;
86}
87
88// Transformation functions
89const parsePrice = (product: RawProduct): ProcessedProduct => ({
90  ...product,
91  price: parseFloat(product.price),
92  tags: product.tags.split(','), // raw pieces, parseTags cleans them up
93  slug: ''
94});
95
96const parseTags = (product: ProcessedProduct): ProcessedProduct => ({
97  ...product,
98  tags: product.tags.map(tag => tag.trim()).filter(tag => tag.length > 0)
99});
100
101const generateSlug = (product: ProcessedProduct): ProcessedProduct => ({
102  ...product,
103  slug: product.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
104});
105
106const validateProduct = (product: ProcessedProduct): ProcessedProduct => {
107  if (product.price <= 0) {
108    throw new Error('Price must be greater than 0');
109  }
110  if (!product.name.trim()) {
111    throw new Error('Product name is required');
112  }
113  return product;
114};
115
116// Processing composition - parsePrice changes the type, so we call it before pipe
117const processProduct = (raw: RawProduct): ProcessedProduct => pipe(
118  parseTags,
119  generateSlug,
120  validateProduct
121)(parsePrice(raw));
122
123const rawProduct: RawProduct = {
124  name: 'Super Laptop',
125  price: '2999.99',
126  description: 'The best laptop on the market',
127  tags: 'laptop, computer, electronics'
128};
129
130try {
131  const processed = processProduct(rawProduct);
132  console.log('Processed product:', processed);
133} catch (error) {
134  console.error('Processing error:', (error as Error).message);
135}

combineValidators collects errors from all validators, and the type-changing step, parsePrice, is called before pipe.

Asynchronous composition

A pipeline also works with promises (Promise). Each step waits for the result of the previous one thanks to then:

1// Composition for asynchronous functions
2const composeAsync = <T>(...functions: Array<(arg: T) => Promise<T>>) => {
3  return (value: T): Promise<T> => {
4    return functions.reduceRight<Promise<T>>(
5      (acc, fn) => acc.then(fn),
6      Promise.resolve(value)
7    );
8  };
9};
10
11const pipeAsync = <T>(...functions: Array<(arg: T) => Promise<T>>) => {
12  return (value: T): Promise<T> => {
13    return functions.reduce<Promise<T>>(
14      (acc, fn) => acc.then(fn),
15      Promise.resolve(value)
16    );
17  };
18};
19
20// Example usage with API
21interface ApiUser {
22  id: number;
23  name: string;
24  email: string;
25}
26
27interface EnrichedUser extends ApiUser {
28  avatar: string;
29  lastLogin: Date;
30  preferences: any;
31}
32
33// Asynchronous functions
34const fetchUser = async (userId: number): Promise<ApiUser> => {
35  console.log(`Fetching user ${userId}`);
36  // API call simulation
37  return new Promise(resolve => {
38    setTimeout(() => {
39      resolve({
40        id: userId,
41        name: 'Jan Kowalski',
42        email: 'jan@example.com'
43      });
44    }, 100);
45  });
46};
47
48const enrichWithAvatar = async (user: ApiUser): Promise<EnrichedUser> => {
49  console.log('Enriching with avatar');
50  return new Promise(resolve => {
51    setTimeout(() => {
52      resolve({
53        ...user,
54        avatar: `https://avatars.example.com/${user.id}`,
55        lastLogin: new Date(),
56        preferences: {}
57      });
58    }, 50);
59  });
60};
61
62const enrichWithPreferences = async (user: EnrichedUser): Promise<EnrichedUser> => {
63  console.log('Enriching with preferences');
64  return new Promise(resolve => {
65    setTimeout(() => {
66      resolve({
67        ...user,
68        preferences: {
69          theme: 'dark',
70          language: 'pl',
71          notifications: true
72        }
73      });
74    }, 50);
75  });
76};
77
78// Asynchronous composition - enrichWithAvatar changes the type (ApiUser -> EnrichedUser),
79// so we call it before the pipeline
80const enrichUser = (user: ApiUser): Promise<EnrichedUser> =>
81  enrichWithAvatar(user).then(pipeAsync(enrichWithPreferences));
82
83// Usage
84async function main() {
85  try {
86    const user = await fetchUser(1);
87    const enrichedUser = await enrichUser(user);
88    console.log('Enriched user:', enrichedUser);
89  } catch (error) {
90    console.error('Error:', error);
91  }
92}
93
94main();

reduce weaves the steps into a single promise chain, preserving the order.

Practical example - E-commerce system with functional programming

Finally, everything together: pure filters, an immutable cart, currying and pipe:

1// Comprehensive example of functional programming application
2
3// Data types
4interface Product {
5  id: string;
6  name: string;
7  price: number;
8  category: string;
9  tags: string[];
10  inStock: boolean;
11  rating: number;
12}
13
14interface CartItem {
15  product: Product;
16  quantity: number;
17}
18
19interface Cart {
20  items: CartItem[];
21  discountCode?: string;
22}
23
24interface Order {
25  id: string;
26  items: CartItem[];
27  subtotal: number;
28  discount: number;
29  tax: number;
30  total: number;
31  timestamp: Date;
32}
33
34// Pure functions for products
35const filterByCategory = (category: string) => (products: Product[]) =>
36  products.filter(product => product.category === category);
37
38const filterByPriceRange = (min: number, max: number) => (products: Product[]) =>
39  products.filter(product => product.price >= min && product.price <= max);
40
41const filterInStock = (products: Product[]) =>
42  products.filter(product => product.inStock);
43
44const sortByPrice = (ascending: boolean = true) => (products: Product[]) =>
45  [...products].sort((a, b) => ascending ? a.price - b.price : b.price - a.price);
46
47const sortByRating = (products: Product[]) =>
48  [...products].sort((a, b) => b.rating - a.rating);
49
50// Cart functions
51const addToCart = (product: Product, quantity: number) => (cart: Cart): Cart => {
52  const existingItemIndex = cart.items.findIndex(item => item.product.id === product.id);
53
54  if (existingItemIndex >= 0) {
55    return {
56      ...cart,
57      items: cart.items.map((item, index) =>
58        index === existingItemIndex
59          ? { ...item, quantity: item.quantity + quantity }
60          : item
61      )
62    };
63  }
64
65  return {
66    ...cart,
67    items: [...cart.items, { product, quantity }]
68  };
69};
70
71const removeFromCart = (productId: string) => (cart: Cart): Cart => ({
72  ...cart,
73  items: cart.items.filter(item => item.product.id !== productId)
74});
75
76const updateQuantity = (productId: string, newQuantity: number) => (cart: Cart): Cart => {
77  if (newQuantity <= 0) {
78    return removeFromCart(productId)(cart);
79  }
80
81  return {
82    ...cart,
83    items: cart.items.map(item =>
84      item.product.id === productId
85        ? { ...item, quantity: newQuantity }
86        : item
87    )
88  };
89};
90
91const applyDiscountCode = (discountCode: string) => (cart: Cart): Cart => ({
92  ...cart,
93  discountCode
94});
95
96// Price calculation functions
97const calculateItemTotal = (item: CartItem): number =>
98  item.product.price * item.quantity;
99
100const calculateSubtotal = (items: CartItem[]): number =>
101  items.reduce((total, item) => total + calculateItemTotal(item), 0);
102
103const calculateDiscount = (subtotal: number, discountCode?: string): number => {
104  const discountRates: Record<string, number> = {
105    'SAVE10': 0.1,
106    'SAVE20': 0.2,
107    'STUDENT': 0.15
108  };
109
110  if (!discountCode || !discountRates[discountCode]) {
111    return 0;
112  }
113
114  return subtotal * discountRates[discountCode];
115};
116
117const calculateTax = (amount: number, taxRate: number = 0.23): number =>
118  amount * taxRate;
119
120const calculateTotal = (subtotal: number, discount: number, tax: number): number =>
121  subtotal - discount + tax;
122
123// Composition for order processing
124const processOrder = (cart: Cart): Order => {
125  const subtotal = calculateSubtotal(cart.items);
126  const discount = calculateDiscount(subtotal, cart.discountCode);
127  const taxableAmount = subtotal - discount;
128  const tax = calculateTax(taxableAmount);
129  const total = calculateTotal(subtotal, discount, tax);
130
131  return {
132    id: 'order_' + Math.random().toString(36).slice(2, 11),
133    items: cart.items,
134    subtotal,
135    discount,
136    tax,
137    total,
138    timestamp: new Date()
139  };
140};
141
142// Search functions with composition
143const searchProducts = (
144  query: string,
145  category?: string,
146  priceRange?: { min: number; max: number },
147  sortBy: 'price' | 'rating' = 'rating'
148) => (products: Product[]): Product[] => {
149  const searchByName = (products: Product[]) =>
150    products.filter(product =>
151      product.name.toLowerCase().includes(query.toLowerCase()) ||
152      product.tags.some(tag => tag.toLowerCase().includes(query.toLowerCase()))
153    );
154
155  const filters = [
156    searchByName,
157    filterInStock
158  ];
159
160  if (category) {
161    filters.push(filterByCategory(category));
162  }
163
164  if (priceRange) {
165    filters.push(filterByPriceRange(priceRange.min, priceRange.max));
166  }
167
168  const sorter = sortBy === 'price' ? sortByPrice() : sortByRating;
169
170  return pipe(...filters, sorter)(products);
171};
172
173// Example usage
174const products: Product[] = [
175  {
176    id: '1',
177    name: 'Laptop Gaming',
178    price: 3500,
179    category: 'electronics',
180    tags: ['gaming', 'laptop', 'high-performance'],
181    inStock: true,
182    rating: 4.8
183  },
184  {
185    id: '2',
186    name: 'Gaming Mouse',
187    price: 150,
188    category: 'electronics',
189    tags: ['gaming', 'mouse', 'rgb'],
190    inStock: true,
191    rating: 4.5
192  },
193  {
194    id: '3',
195    name: 'JavaScript Book',
196    price: 89,
197    category: 'books',
198    tags: ['programming', 'javascript', 'learning'],
199    inStock: false,
200    rating: 4.9
201  }
202];
203
204// Creating cart functionally
205const emptyCart: Cart = { items: [] };
206
207const myCart = pipe(
208  addToCart(products[0], 1),
209  addToCart(products[1], 2),
210  applyDiscountCode('SAVE10')
211)(emptyCart);
212
213console.log('Cart:', myCart);
214
215// Processing the order
216const order = processOrder(myCart);
217console.log('Order:', order);
218
219// Searching products
220const searchGaming = searchProducts('gaming', 'electronics', undefined, 'price');
221const gamingProducts = searchGaming(products);
222console.log('Gaming products:', gamingProducts);
223
224// Partial application: the product is fixed, the quantity comes later
225const addLaptopToCart = (quantity: number) => addToCart(products[0], quantity);
226const addMouseToCart = (quantity: number) => addToCart(products[1], quantity);
227
228const cartWithLaptop = addLaptopToCart(1)(emptyCart);
229const cartWithBoth = addMouseToCart(2)(cartWithLaptop);
230
231console.log('Cart with laptop and mouse:', cartWithBoth);

The cart is never modified: every operation returns a new version.

Summary of functional programming

  1. Pure functions - predictability and ease of testing
  2. Immutability - safety and no side effects
  3. Currying - flexibility and reusability
  4. Composition - building complex logic from simple elements

You gain predictable code, easy unit tests, better conditions for parallel processing and fewer bugs caused by state mutation. The functional style shines in data transformations, heavy computations, systems that require high reliability and code you test often. In the editor below you will build a dinosaur registry, and in the next lesson pure functions come back as reducers.

"A pure function is a lab with closed doors: arguments go in, a result comes out," says Dr. Rex.

Code for this lesson: index.js
1// Factory pattern - Dinosaur Registry
2// Exercise: Implement a dinosaur factory and registry
3
4class Dinosaur {
5  constructor(name, species, diet) {
6    this.name = name;
7    this.species = species;
8    this.diet = diet;
9    this.health = 100;
10  }
11
12  getInfo() {
13    return this.name + " (" + this.species + ") - " + this.diet;
14  }
15}
16
17class DinosaurRegistry {
18  constructor() {
19    this.dinosaurs = [];
20  }
21
22  // Implement the methods:
23  // create(name, species, diet) - creates and registers a dinosaur
24  // findBySpecies(species) - searches by species
25  // getAll() - returns all
26
27  create(name, species, diet) {
28    // Your code here
29  }
30
31  findBySpecies(species) {
32    // Your code here
33  }
34}
35
36const registry = new DinosaurRegistry();
37console.log("Dinosaur registry ready");

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. 1. When is it best to use the Observer pattern?

  2. 2. What is a higher-order function?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    The index.js file has a DinosaurRegistry class with register, update and find methods. Fill in the blanks: ___BLANK1___ copies all fields of the old record into the new object (as in the immutability lesson), ___BLANK2___ is the array method that keeps only the matching records, and ___BLANK3___ is the array method that returns true only when the record matches every criterion. update should return a new object and leave the old one unchanged, and find({ zone: 'A', species: 'T-Rex' }) only the records that match both conditions.

  • Click in order

    Arrange the elements of a simple factory (Factory pattern) in the correct order:

  • Code editor

    The index.js file has a SecuritySystem class with sensors and observers (Observer pattern). Fill in the blanks: ___BLANK1___ is the array method that adds the observer in subscribe, ___BLANK2___ is the observer method that notify calls with the alert, and ___BLANK3___ is the comparison operator that gives an alert only to a sensor whose reading is greater than its threshold (a reading equal to the threshold is not an alarm). checkAlerts should return alerts { sensorId, zone, value } and notify all observers about each of them, and triggerEmergencyProtocol(reason) should turn on emergency mode and send the observers the alert { type: 'emergency', reason }.

Useful articles