Функциональное программирование (ФП) в JavaScript — это стиль написания кода, при котором программа собирается из небольших функций: они получают данные через аргументы, возвращают новый результат и не меняют ничего снаружи. JavaScript не является чисто функциональным языком, он мультипарадигменный: ФП здесь — набор приемов, которые язык поддерживает, а не обязательный режим.
Содержание
- Четыре термина, которые путают
- Минимальный пример: цикл против цепочки функций
- Чистые функции и побочные эффекты
- Неизменяемость: где JavaScript подводит
- map, filter, reduce: что выбрать
- Своя функция высшего порядка
- Замыкания
- Каррирование и частичное применение
- Композиция: pipe и compose
- Где ФП в JavaScript упирается в ограничения
- Выводы
- Где применяется / связь с практикой
- FAQ
Опора ФП на 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 хватает. Библиотеки полезны, когда в проекте много композиции и нужны готовые каррированные утилиты.



