Функциональное программирование в JavaScript: чистые функции, map/filter/reduce, замыкания и композиция

Функциональное программирование в JavaScript: чистые функции, map/filter/reduce, замыкания и композиция Полезное

Функциональное программирование (ФП) в JavaScript — это стиль написания кода, при котором программа собирается из небольших функций: они получают данные через аргументы, возвращают новый результат и не меняют ничего снаружи. JavaScript не является чисто функциональным языком, он мультипарадигменный: ФП здесь — набор приемов, которые язык поддерживает, а не обязательный режим.

Опора ФП на JS — пять вещей: чистые функции, неизменяемость данных, функции высшего порядка (map, filter, reduce), замыкания и композиция. Ниже каждая разобрана на коде с реальным выводом. Все примеры проверены 24.09.2026 в Node.js 24.21 (текущая LTS) и 22.23; в современных браузерах они работают так же, но тексты ошибок и формат вывода консоли там могут отличаться.

Четыре термина, которые путают

  • Функция первого класса — свойство языка: функцию можно положить в переменную, передать аргументом и вернуть из другой функции. В JavaScript все функции такие.
  • Функция высшего порядка — конкретная функция, которая принимает функцию или возвращает функцию. map — функция высшего порядка, потому что принимает колбэк.
  • Чистая функция — при одинаковых аргументах всегда возвращает одинаковый результат и не имеет побочных эффектов (не меняет внешние переменные, аргументы, не пишет в сеть, файл или консоль).
  • Замыкание — функция вместе с переменными той области видимости, где она была создана. Благодаря замыканию функция помнит эти переменные после выхода из внешней функции.

Первое делает возможным второе: без функций первого класса не было бы map и filter. Чистота и замыкания — независимые свойства: замыкание может быть и чистым, и нечистым.

Минимальный пример: цикл против цепочки функций

Задача — посчитать выручку по оплаченным заказам. Сначала императивно, потом в функциональном стиле:

const orders = [
  { id: 1, customer: "Анна", total: 1200, paid: true },
  { id: 2, customer: "Борис", total: 800, paid: false },
  { id: 3, customer: "Анна", total: 3500, paid: true },
  { id: 4, customer: "Вера", total: 450, paid: true },
];

// Императивно: цикл и изменяемая переменная
let revenueLoop = 0;
for (let i = 0; i < orders.length; i++) {
  if (orders[i].paid) {
    revenueLoop += orders[i].total;
  }
}

// Функционально: цепочка функций высшего порядка
const revenue = orders
  .filter((order) => order.paid)
  .map((order) => order.total)
  .reduce((sum, total) => sum + total, 0);

console.log(revenueLoop, revenue); // 5150 5150
console.log(orders.length);        // 4 - исходный массив не изменился

Оба варианта выводят 5150. Разница в том, как читается код: цепочка описывает, ЧТО сделать с данными (отобрать оплаченные, взять суммы, сложить), а цикл — КАК пройти по индексам. Каждый шаг цепочки создает новый массив и не трогает orders.

Чистые функции и побочные эффекты

// Нечистая: зависит от внешней переменной и меняет ее
let discount = 10;
let calls = 0;
function priceWithDiscountImpure(price) {
  calls++;
  return price - (price * discount) / 100;
}

// Чистая: все, что нужно, приходит через аргументы
function priceWithDiscount(price, percent) {
  return price - (price * percent) / 100;
}

console.log(priceWithDiscountImpure(1000)); // 900
discount = 20;
console.log(priceWithDiscountImpure(1000)); // 800 - тот же вход, другой результат
console.log(priceWithDiscount(1000, 10));   // 900 - всегда 900 при тех же аргументах
console.log(calls);                         // 2 - побочный эффект

Чистую функцию легко тестировать (достаточно сравнить результат) и безопасно переиспользовать. Но программа без побочных эффектов бесполезна: запросы к API, запись в DOM и логирование нужны. Практическое правило ФП — не «только чистые функции», а «держать эффекты на краях программы»: чистое ядро считает, а тонкий слой вокруг читает ввод и выводит результат.

Неизменяемость: где JavaScript подводит

Ключевое слово const запрещает переприсвоить переменную, но не делает объект или массив неизменяемым. Частая ошибка — метод, который молча меняет исходные данные:

const prices = [300, 100, 200];
const sorted = prices.sort((a, b) => a - b);
console.log(sorted); // [ 100, 200, 300 ]
console.log(prices); // [ 100, 200, 300 ] - исходный массив тоже изменился
console.log(sorted === prices); // true

sort, reverse, splice, push, pop меняют массив на месте. Исправление — копирующие методы ES2023 (toSorted, toReversed, toSpliced, with) или явная копия; для объектов — spread с копированием каждого изменяемого уровня:

const prices = [300, 100, 200];
const sorted = prices.toSorted((a, b) => a - b); // ES2023
const copySorted = [...prices].sort((a, b) => a - b); // вариант для старых сред
console.log(sorted, copySorted); // [ 100, 200, 300 ] [ 100, 200, 300 ]
console.log(prices);             // [ 300, 100, 200 ]

const user = { name: "Анна", address: { city: "Казань" } };
const moved = { ...user, address: { ...user.address, city: "Самара" } };
console.log(user.address.city, moved.address.city); // Казань Самара

Object.freeze тоже защищает только верхний уровень:

"use strict";
const config = Object.freeze({ mode: "prod", limits: { rps: 100 } });
config.limits.rps = 5;           // вложенный объект не заморожен
console.log(config.limits.rps);  // 5
config.mode = "dev";             // TypeError в строгом режиме
5
TypeError: Cannot assign to read only property 'mode' of object '#<Object>'

В нестрогом режиме (обычный скрипт без "use strict") последняя строка не упадет, а молча ничего не сделает; в ES-модулях и внутри классов строгий режим включен всегда. Для глубокой заморозки freeze применяют рекурсивно или используют библиотеки неизменяемых структур.

map, filter, reduce: что выбрать

Задача Метод Что возвращает Меняет исходный массив
Преобразовать каждый элемент map новый массив той же длины нет
Оставить элементы по условию filter новый массив, длина от 0 до исходной нет
Свести массив к одному значению (сумма, объект, строка) reduce любое значение нет
Просто выполнить действие для каждого (вывод, запись) forEach undefined нет, но это место для побочных эффектов

Колбэк map и filter получает три аргумента: (element, index, array). Колбэк reduce — четыре: (accumulator, currentValue, currentIndex, array). Из-за лишних аргументов возникает классическая ловушка:

console.log(["1", "2", "3"].map(parseInt));             // [ 1, NaN, NaN ]
console.log(["1", "2", "3"].map((s) => parseInt(s, 10))); // [ 1, 2, 3 ]
console.log(["1", "2", "3"].map(Number));                // [ 1, 2, 3 ]

parseInt принимает второй параметр — основание системы счисления, и map передает туда индекс: parseInt("2", 1) и parseInt("3", 2) дают NaN. Правило: передавать в map готовую функцию без обертки можно, только если знаешь все ее параметры.

Вторая ловушка — reduce без начального значения. На непустом массиве он возьмет первый элемент как аккумулятор, а на пустом упадет:

const empty = [];
console.log(empty.reduce((sum, x) => sum + x, 0)); // 0
console.log(empty.reduce((sum, x) => sum + x));    // TypeError
0
TypeError: Reduce of empty array with no initial value

Начальное значение стоит передавать всегда — оно заодно фиксирует тип результата. reduce умеет собирать и объект, например суммы по клиентам:

const orders = [
  { customer: "Анна", total: 1200 },
  { customer: "Борис", total: 800 },
  { customer: "Анна", total: 3500 },
];
const byCustomer = orders.reduce(
  (acc, { customer, total }) => ({ ...acc, [customer]: (acc[customer] ?? 0) + total }),
  {}
);
console.log(byCustomer); // { 'Анна': 4700, 'Борис': 800 }

Граница: spread внутри reduce копирует аккумулятор на каждом шаге, на десятках тысяч ключей это заметно медленнее. Для больших данных допустимо менять локальный аккумулятор, созданный внутри reduce: снаружи эта мутация не видна. Для группировки в Node.js 21+ и современных браузерах есть готовый Object.groupBy.

Своя функция высшего порядка

Чтобы понять, как устроен map, его легко написать самому. Вторая функция — пример обратного случая: она не принимает, а возвращает функцию.

function mapForEach(arr, fn) {
  const result = [];
  for (let i = 0; i < arr.length; i++) {
    result.push(fn(arr[i], i));
  }
  return result;
}

const words = ["js", "функция", "map"];
console.log(mapForEach(words, (w) => w.length)); // [ 2, 7, 3 ]

function multiplier(factor) {
  return (x) => x * factor;
}
const double = multiplier(2);
console.log(mapForEach([1, 2, 3], double)); // [ 2, 4, 6 ]

Внутри mapForEach есть цикл и push, но снаружи она чистая при условии, что чиста переданная fn: сам mapForEach вход не меняет, а результат зависит только от аргументов. Так устроено большинство ФП-утилит — императивная реализация за чистым интерфейсом.

Замыкания

multiplier(2) вернул функцию, которая помнит factor = 2. Это и есть замыкание. Его главное применение — приватное состояние без классов:

function createCounter() {
  let count = 0;
  return {
    increment: () => ++count,
    value: () => count,
  };
}
const c1 = createCounter();
const c2 = createCounter();
c1.increment();
c1.increment();
c2.increment();
console.log(c1.value(), c2.value()); // 2 1
console.log(c1.count);               // undefined - снаружи переменной не достать

Каждый вызов createCounter создает свою переменную count. Обратите внимание: счетчик — удобный прием, но не чистая функция, increment меняет состояние. Типичная ошибка с замыканиями — var в цикле:

const fns = [];
for (var i = 0; i < 3; i++) fns.push(() => i);
console.log(fns.map((f) => f())); // [ 3, 3, 3 ]

const fixed = [];
for (let j = 0; j < 3; j++) fixed.push(() => j);
console.log(fixed.map((f) => f())); // [ 0, 1, 2 ]

У var одна переменная на весь цикл, и все функции видят ее последнее значение. let создает новую привязку на каждой итерации.

Каррирование и частичное применение

Эти понятия часто смешивают. Каррирование превращает функцию от нескольких аргументов в цепочку функций, принимающих их по одному: f(a, b, c) -> f(a)(b)(c). Частичное применение фиксирует часть аргументов сразу и возвращает функцию от остальных.

// Каррирование вручную: по одному аргументу за вызов
const addTax = (rate) => (price) => Math.round(price * (1 + rate / 100));
const withVat = addTax(20);
console.log(withVat(1000)); // 1200

// Универсальный curry для функций с фиксированным числом параметров
function curry(fn) {
  return function curried(...args) {
    return args.length >= fn.length
      ? fn(...args)
      : (...more) => curried(...args, ...more);
  };
}
const volume = (a, b, c) => a * b * c;
const cVolume = curry(volume);
console.log(cVolume(2)(3)(4), cVolume(2, 3)(4), cVolume(2)(3, 4)); // 24 24 24

// Частичное применение: зафиксировали часть аргументов сразу
const greet = (greeting, name) => `${greeting}, ${name}!`;
const hello = greet.bind(null, "Привет");
console.log(hello("Анна")); // Привет, Анна!

// Граница: fn.length не считает параметры по умолчанию и rest
console.log(((a, b = 1) => a + b).length, ((...xs) => xs).length); // 1 0

Такой curry полагается на fn.length, поэтому для функций с параметрами по умолчанию или ...rest он сработает раньше времени. Строго говоря, это «ослабленное» каррирование: curry выше допускает и несколько аргументов за вызов, как в библиотеках Ramda и Lodash.

Композиция: pipe и compose

Композиция склеивает маленькие функции в одну: результат первой идет на вход второй. pipe применяет функции слева направо, compose — справа налево, как в математике f(g(x)).

const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);
const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);

const trim = (s) => s.trim();
const lower = (s) => s.toLowerCase();
const slugify = (s) => s.replace(/\s+/g, "-");

const toSlug = pipe(trim, lower, slugify);
const toSlug2 = compose(slugify, lower, trim);

console.log(toSlug("  Functional Programming JS ")); // functional-programming-js
console.log(toSlug2("  Functional Programming JS ")); // functional-programming-js

Композиция работает, когда каждая функция принимает один аргумент — поэтому ее и дополняет каррирование. Встроенного оператора конвейера |> в JavaScript пока нет, это предложение в процессе стандартизации, так что pipe пишут сами или берут из библиотеки.

Где ФП в JavaScript упирается в ограничения

Рекурсия вместо циклов — классика ФП, но в JavaScript глубина стека ограничена:

const sumTo = (n) => (n === 0 ? 0 : n + sumTo(n - 1));
console.log(sumTo(1000)); // 500500
try {
  sumTo(1e6);
} catch (e) {
  console.log(e.name + ": " + e.message);
}
500500
RangeError: Maximum call stack size exceeded

Оптимизация хвостовых вызовов есть в стандарте, но V8 (Node.js, Chrome) ее не реализует, поэтому для больших данных нужен цикл или reduce. К тому же sumTo выше и не хвостовая: после рекурсивного вызова еще выполняется сложение n + ..., так что ей не помогла бы и оптимизация — для нее рекурсию переписывают с аккумулятором (n, acc = 0) => (n === 0 ? acc : sumTo(n - 1, acc + n)), и это имеет смысл только в движках с TCO (Safari). Вторая граница — цепочка filter().map().reduce() проходит по данным несколько раз и создает промежуточные массивы; на обычных объемах это незаметно, на горячем пути с миллионами элементов стоит измерить и при необходимости свести все в один reduce или цикл.

Когда ФП-стиль уместен: преобразование данных (ответы API, отчеты, формы), состояние в React и Redux, где неизменяемость обязательна для корректной перерисовки, утилиты и валидация. Когда нет: низкоуровневая работа с DOM, горячие циклы, код, где команда не знает приемов, — там простой цикл читается лучше. Теорию парадигмы в целом, без привязки к JavaScript, разбирает статья функциональное программирование от А до Я.

Выводы

  • ФП в JavaScript — стиль, а не режим языка: чистые функции, неизменяемость, функции высшего порядка, замыкания и композиция.
  • const и Object.freeze не дают глубокой неизменяемости; sort и reverse меняют исходный массив, копирующие аналоги — toSorted и toReversed.
  • В reduce всегда передавайте начальное значение; в map не передавайте функции с лишними параметрами вроде parseInt.
  • Каррирование — цепочка функций по одному аргументу, частичное применение — фиксация части аргументов; оба нужны, чтобы собирать функции через pipe.
  • Глубокая рекурсия в V8 упирается в размер стека, поэтому для больших данных используют цикл или reduce.

Где применяется / связь с практикой

Освойте тему на практике

Приемы из статьи — ежедневная работа фронтенд- и Node.js-разработчика: обработка ответов API через map и reduce, неизменяемые обновления состояния в React и Redux, композиция middleware, замыкания в хуках и обработчиках событий. Системно разобрать их вместе с асинхронностью, типизацией и архитектурой приложений можно на курсе «JavaScript-разработчик. Продвинутый уровень». Попробовать формат и задать вопросы преподавателям можно на открытых уроках Otus.

FAQ

Стрелочная функция — это и есть функциональное программирование?
Нет, это только короткий синтаксис. Стрелочная функция может менять внешние переменные, а обычная function может быть чистой; для ФП важно поведение функции, а не форма записи.

Чем forEach отличается от map?
forEach ничего не возвращает и нужен для побочных эффектов, map возвращает новый массив. Если результат map не используется, это признак того, что нужен forEach или обычный цикл.

Нужна ли библиотека вроде Ramda или Lodash/fp?
Для старта нет: встроенных методов массивов и пары своих функций pipe и curry хватает. Библиотеки полезны, когда в проекте много композиции и нужны готовые каррированные утилиты.

OTUS Журнал