Мотивацией для написания этого поста стали два года собеседований JS/TS-инженеров. Я интересуюсь языками и функциональным программированием, поэтому всегда «разбавлял» технические вопросы разговором о парадигмах. И заметил любопытную асимметрию.
Об ООП кандидаты рассуждали уверенно — но в основном на концептуальном уровне, не вдаваясь в то, как именно ООП реализовано в JavaScript. С FP картина была другой: уверенности меньше, зато критика — конкретная и повторяющаяся: «иммутабельность дорогая по памяти», «рекурсия небезопасна из-за стека». Что характерно — эти аргументы почти всегда были сформулированы через опыт работы с JS, а не с Haskell, Clojure или Scala.
Это важная деталь. Любая парадигма, на мой взгляд, существует как минимум на двух уровнях: концептуальном (идеальная модель) и имплементационном (как конкретный язык эту модель выражает). Судить о FP по JS — примерно то же самое, что судить об ООП по bash-скриптам с глобальными переменными.
Параллельно я регулярно слышал, что JS — функциональный язык. Аргументы варьировались от «там есть .map()» до рассуждений о чистых функциях и каррировании. Именно это и стало поводом для поста: я хочу объяснить, что я считаю функциональным языком — и почему JS таковым не является. Не перечислить отсутствующие фичи, а показать, почему их нет и что это значит в реальном рантайме.
Уточнение: далее JS и TS используются как взаимозаменяемые понятия, кроме случаев, когда речь идёт о системе типов — тогда я указываю TS явно.
1. Мутабельность по умолчанию
В Haskell вы физически не можете изменить переменную. В Clojure все базовые структуры иммутабельны из коробки. В JS всё строго наоборот:
const arr = [1, 2, 3];arr.push(4);console.log(arr); // [1, 2, 3, 4]const user = { name: "Alice" };user.name = "Bob"; // Работает
Пример выше — с массивом, но ситуация с объектами, мапами, сэтами — аналогичная.
const — это запрет на переприсваивание ссылки, а не на мутацию данных. Проще говоря: коробку менять нельзя, а всё что внутри — пожалуйста.
Так же, в Scala cуществуют кейс-классы (case class), которые буквально являются способом для моделиврования иммутабельных данных:
case class User(name: String)val alice = User("Alice")val bob = alice.copy(name = "Bob") // alice не изменился
Почему это важно? Мутабельность по умолчанию ломает ссылочную прозрачность (это возможность заменить выражение его значением без изменения поведения программы) и делает невозможными гарантии, на которых строится функциональный дизайн. Да, есть Object.freeze() и библиотеки вроде Immutable.js. Но они — костыли поверх языка, спроектированного с мутабельностью в голове. Решения об использовании подобных библиотек полностью лежит на конкретной команде и никак не «форсируется».
Почему так? JS создавался в 1995 году за 10 дней как скриптовый язык для браузера. Модель «всё мутабельно и лежит в куче» была самой простой для реализации и понятной для программистов, привыкших к C/Java. Перепроектировать модель памяти спустя 30 лет, не сломав веб — невозможно.
2. Нет оптимизации хвостовой рекурсии (TCO)
На мой взгляд, это один из самых убедительных аргументов: TCO вошёл в стандарт ES2015. Сегодня его поддерживает только Safari. V8 (Chrome, Node.js) — нет. SpiderMonkey (Firefox) — нет.
function factorial(n, acc = 1) { if (n <= 1) return acc; return factorial(n - 1, n * acc); // хвостовой вызов — но TCO не работает}factorial(100000); // RangeError: Maximum call stack size exceeded
В Scala та же функция с аннотацией @tailrec не просто работает — компилятор гарантирует оптимизацию ещё до рантайма:
import scala.annotation.tailrec@tailrecdef factorial(n: Int, acc: Long = 1): Long = { if (n <= 1) acc else factorial(n - 1, n * acc)}factorial(100000) // Работает корректно// Если хвостовой вызов невозможен — ошибка компиляции,// не RangeError на продакшене
Ключевой момент: в Scala вы узнаёте о проблеме в момент написания кода. В JS — в рантайме.
Почему V8 не делает TCO? Это же просто замена вызова на jmp?
Технически — да. Но в динамическом языке с eval, Function.caller и DevTools есть нюансы:
-
Потеря стектрейсов. При TCO хвостовой вызов заменяет текущий фрейм. В стеке остаётся только один фрейм вместо цепочки. Для отладки это катастрофа. В Scala такой проблемы нет, т.к. компилятор просто превращает хвостовую рекурсию в обычный
whileна этапе сборки. -
Совместимость со старыми API и отладка. В JS существуют устаревшие API вроде
Function.caller, которые позволяют узнать, кто вызвал функцию. TCO разрушает эту информацию, так как стирает историю вызовов. -
Сложность реализации в JIT. V8 использует многоуровневую компиляцию (Ignition → Sparkplug → Maglev → TurboFan). TCO требует пересмотра того, как генерируются и инвалидируются деоптимизированные фреймы.
Инженеры V8 открыто заявляли: цена реализации TCO в текущей архитектуре превышает пользу для экосистемы.
3. Ленивые и персистентные коллекции
Посмотрим на пиковое потребление памяти и на то, как вообще выполняется типичная JS-цепочка:
const result = hugeArray .filter(x => x > 0) // [1] создаётся массив A // в памяти: hugeArray + A .map(x => x * 2) // [2] создаётся массив B // в памяти: hugeArray + A + B // A больше не нужен — но GC ещё не пришёл .filter(x => x < 100) // [3] создаётся массив C // в памяти: hugeArray + B + C (+ возможно A) .slice(0, 10); // [4] создаётся result из 10 элементов // C больше не нужен
В худшем случае — момент между шагами 2 и 3 — в памяти одновременно живут hugeArray, A и B. GC не синхронный: массив помечается как кандидат на удаление в момент, когда на него перестают ссылаться, но реально освобождается позже — по собственному расписанию движка. На больших данных это означает реальный memory spike в середине цепочки, даже если финальный результат крошечный.
В Scala .view превращает цепочку в единый поэлементный конвейер без промежуточных коллекций:
val result = hugeArray.view .filter(_ > 0) .map(_ * 2) .filter(_ < 100) .take(10) .toList// Каждый элемент проходит через все три операции ровно один раз.// Как только набрано 10 элементов — обработка прекращается.
Что насчёт генераторов?
В JS есть генераторы, и технически они позволяют сделать нечто похожее:
function* lazyPipeline(arr, filterFn, mapFn) { for (const x of arr) { if (filterFn(x)) { yield mapFn(x); } }}const result = [];let count = 0;for (const item of lazyPipeline(hugeArray, x => x > 0, x => x * 2)) { result.push(item); if (++count >= 10) break;}
Это работает, но обратите внимание на то, во что превратился код: вместо декларативной цепочки — ручной цикл со счётчиком и break. Генераторы — это низкоуровневый примитив, а не стандартный API коллекций. В Scala .view — одно слово, встроенное в язык. В JS — отдельная функция-генератор, которую нужно написать самому, и императивный цикл снаружи. Разница не в том, можно ли — а в том, насколько это естественно и / или декларативно.
Примечание: после написания статьи уточнил, что в ES2025 в JS добавили Iterator Helpers, которые дают вам ленивое вычисление и потенциально бесконечные стримы, поэтому для точности решил указать это здесь. Они решают проблему с довольно специфическим и сложночитаемым синтаксисом, который приведён выше, но не решают проблему, описанную ниже.
Structural Sharing
// list1 — уже существующий списокval list1 = List(2, 3, 4)// Добавляем 1 в голову — получаем list2val list2 = 1 :: list1 // List(1, 2, 3, 4)// В памяти создался ровно один новый узел — голова со значением 1.// Хвост (2 -> 3 -> 4) не копировался — list2 просто ссылается на list1.// list2: [1] -> [2] -> [3] -> [4]// ↑// здесь начинается list1// оба списка живут одновременно
O(1) по памяти и времени. Для более сложных структур (Scala Vector, Clojure Persistent Collections) используется структурный обмен на основе префиксных деревьев. При «изменении» элемента копируется только путь от корня до листа — O(log n). Остальной граф переиспользуется по ссылке:
val v1 = Vector(1, 2, 3, 4, 5)val v2 = v1.updated(2, 99) // "меняем" третий элемент// v1 = Vector(1, 2, 3, 4, 5) — не изменился// v2 = Vector(1, 2, 99, 4, 5)// Скопировано: O(log n) узлов. Остальное — общие ссылки.
В JS [...arr] — всегда полная копия. Персистентных структур с structural sharing нет из коробки.
Почему JS не делает ленивые коллекции по умолчанию?
-
Eager evaluation дружит с CPU-кэшем. Массив в JS — непрерывный кусок памяти (FixedArray / Fast Elements). Проход по нему предсказуем для prefetcher’а. Ленивый конвейер на генераторах порождает много мелких вызовов итераторов, что разрушает локальность данных.
-
JIT-оптимизации массивов. V8 агрессивно инлайнит методы
Array.prototype. Для ленивых цепочек таких оптимизаций нет. -
Structural sharing vs cache locality. Персистентные структуры используют деревья с широким ветвлением. Доступ к элементу — несколько разыменований указателей. На массиве — один offset. Для UI-рендеринга, где данные читаются линейно, массивы с копированием могут быть быстрее, несмотря на аллокации.
Язык оптимизирован под мейнстримный сценарий, а не под обработку больших данных.
4. Ошибки — это не значения
В функциональном программировании ошибки — это просто данные. В JavaScript ошибки — это «взрывы» потока управления.
Рассмотрим простую операцию:
// JSON.parse: (string) => anyconst data = JSON.parse(userInput);
JSON.parse может выбросить ошибку, но это скрыто от системы типов. Сигнатура (string) => any говорит: «Я всегда возвращаю значение». На деле эта функция может бросить исключение, если в процессе парсинга строки что-то пойдет не так. Исключение — это незаявленный управляющий эффект: он невидим для компилятора, не отражён в типе и не вынуждает вызывающий код его обработать. На моей практике это довольно частый источник багов (люди — не роботы, забудете обернуть в try/catch, — получите exception).
Кроме прочего, конструкция throw не ссылочно-прозрачна по определению и в целом может вести себя своеобразно:
throw 1 // работаетthrow "asd" // работаетthrow new Error("что-то не так") // работаетthrow [] // порядокthrow {} // тоже порядок
В TS не существует никакого контракта на то, что именно будет выброшено — ни в типе, ни в сигнатуре:
function getUser(id: string): User { // Выглядит безопасно. // Может бросить исключение. Вы не знаете.}
Taким образом, throw переводит функцию из полной (total) в частичную (partial) и мы никаким образом не можем узнать об этом, кроме как прочитать всё тело функции. Это в буквальном смысле «слепая зона» системы типов.
В функциональных языках ошибка закодирована в возвращаемом типе:
val result: Try[Json] = Try(parse(userInput))val upperName = result.map(_.user.name.toUpperCase)// По-прежнему Try — Success или Failure
Ошибки — это значения, они композируются. Тип Try[Json] честно сообщает: «это вычисление может не получиться» — и компилятор не даст вам обратиться к результату, не обработав оба случая.
Инструкции против выражений
В ФП всё является значением. В JS обработка ошибок (как и if, while, for) — нет.
// В JS невозможноconst result = try { riskyOperation()} catch (e) { handleError(e)}
try/catch — это инструкция, а не выражение, её нельзя композировать и приходится разрывать «поток».
Обходной путь
Библиотеки вроде fp-ts или Effect возвращают ошибки как данные:
import { pipe } from 'fp-ts/function'import * as E from 'fp-ts/Either'// Функция-обёртка для обработки потенциальной ошибкиconst parseJSON = (input: string): E.Either<Error, any> => E.tryCatch( () => JSON.parse(input), (reason) => reason instanceof Error ? reason : new Error(String(reason)) )const result = pipe( parseJSON(input), E.map(data => data.user.name.toUpperCase()))// result: E.Either<Error, string>
Но обратите внимание на ключевую деталь: вам пришлось вручную обернуть JSON.parse. Сам язык остаётся неосведомлённым об эффектах.
Почему так сложилось?
Исключения в JavaScript — это механизм потока управления, а не модель данных. Они пришли из C++/Java 90-х, где цели были иными:
-
избежать загрязнения возвращаемых типов
-
обрабатывать «исключительные» ситуации без ручных проверок повсюду
-
быстро и дёшево раскручивать стек
Такая модель имела смысл для скриптового языка в браузере:
-
большинство отказов были внешними (сетевые ошибки, действия пользователя)
-
накладные расходы по памяти имели значение
-
явные типы ошибок усложнили бы простой код
JavaScript унаследовал эту модель — и она прижилась.
5. Нет синтаксической поддержки монад
Технически Promise — это монада (почти). Array с .flatMap() — тоже монада. JS позволяет выражать монадические паттерны. Но между «позволяет» и «поддерживает» — пропасть.
В Scala есть for-comprehension:
val result = for { user <- findUser(id) // Option[User] address <- user.address // Option[Address] city <- address.city // Option[String]} yield city
В JS то же самое — вложенные .then() или .flatMap(). Язык не знает, что вы работаете с монадой, и никак вам в этом не помогает.
Почему в JS нет сахара для монад? Потому что монады — абстракция над типами высшего порядка (HKT). А их нет (см. пункт 6). Нечто похожее на for-comprehension есть в промисах, специальный синтаксис async/await. Но это никак нельзя назвать «общим» механизмом, это буквально частный случай.
6. Нет типов высшего порядка (Higher-Kinded Types)
Напишем немного абстракций: В Scala можно написать обобщённый Functor:
trait Functor[F[_]] { def map[A, B](fa: F[A])(f: A => B): F[B]}
Это значит: «Для любого типа F, принимающего один параметр, я могу определить, как работает map». Будь то List, Option, Future — одна абстракция, работающая для всех.
TypeScript этого не умеет. Библиотека fp-ts вынуждена эмулировать HKT через ручной реестр типов:
// Шаг 1: реестр — словарь вида "строка → реальный тип"// Это единственный способ научить TS понимать, что 'Array' — это Array<A>interface URItoKind<A> { readonly Array: Array<A> readonly Option: Option<A> // каждый новый тип регистрируется здесь вручную}// Шаг 2: URIS — это просто объединение всех зарегистрированных строк// type URIS = 'Array' | 'Option' | ...type URIS = keyof URItoKind<unknown>// Шаг 3: Kind — это indexed access type (lookup по реестру)// Kind<'Array', number> → URItoKind<number>['Array'] → Array<number>// Kind<'Option', string> → URItoKind<string>['Option'] → Option<string>// Именно здесь строка превращается обратно в реальный generic-типtype Kind<F extends URIS, A> = URItoKind<A>[F]// Шаг 4: теперь можно написать "обобщённый" Functor// но кавычки здесь не случайны — это не настоящий type constructor polymorphism,// а его эмуляция через таблицу строкinterface Functor<F extends URIS> { map<A, B>(fa: Kind<F, A>, f: (a: A) => B): Kind<F, B>}
Для меня это выглядит как борьба с ограничениями языка, причём борьба, которую авторы fp-ts ведут с гениальностью и упорством. Не смотря на действительно очень креативное решение, выглядит и читается подобное сложно, в особенности для тех людей, кто прежде вообще не сталкивался с типами высшего порядке и не до конца понимает, зачем они могут быть нужны.
Почему TypeScript не добавляет HKT? TS — надмножество JavaScript со структурной типизацией. HKT требуют kind-полиморфизма в компиляторе. Внедрение этого в структурную систему потребовало бы фундаментального пересмотра алгоритма вывода типов. Команда TS обсуждала это и пришла к выводу, что цена слишком высока для типичного TS-проекта. Опять компромисс.
Отдельного упоминания заслуживает Effect TS — современная библиотека, которая идёт в обход проблемы HKT совершенно иначе. Вместо эмуляции через URI-реестр она строит собственную систему эффектов поверх одного центрального типа
Effect<A, E, R>, который кодирует сразу успех, ошибку и зависимости. По сути это полноценный effect system в духе ZIO из Scala — со structured concurrency, dependency injection через контекст и composable error handling. Effect не притворяется, что решает проблему HKT в общем виде, но для задачи «писать надёжный, композируемый код с управляемыми эффектами» предлагает более честный и практичный ответ, чем fp-ts. Показательно, что он набирает популярность именно среди тех, кто приходит в TS из Scala или Haskell и не готов мириться с процедурным хаосом.
7. Нет pattern matching
В функциональных языках паттерн-матчинг — первоклассная конструкция с exhaustiveness checking. В Scala компилятор выдаст предупреждение, если вы забыли обработать новый подтип:
sealed trait Shapecase class Circle(radius: Double) extends Shapecase class Rectangle(w: Double, h: Double) extends Shapedef area(s: Shape): Double = s match { case Circle(r) => Math.PI * r * r case Rectangle(w, h) => w * h // Добавите Triangle и забудете здесь — компилятор предупредит // до того, как код уйдёт в продакшен}
В JS есть switch/case, но он не работает со структурами данных, не гарантирует exhaustiveness, не деструктурирует автоматически и требует явного break — традиционного источника багов.
В TypeScript есть discriminated unions, и на первый взгляд они выглядят как решение:
type Shape = | { kind: "circle"; radius: number } | { kind: "rectangle"; width: number; height: number }function area(shape: Shape): number { switch (shape.kind) { case "circle": return Math.PI * shape.radius ** 2 case "rectangle": return shape.width * shape.height }}
TS проверит exhaustiveness — но только внутри файла. Ничто не помешает кому-то в другом файле расширить тип:
// Другой файл, другой разработчик, три месяца спустяtype Shape = | { kind: "circle"; radius: number } | { kind: "rectangle"; width: number; height: number } | { kind: "triangle"; base: number; height: number } // Добавили!// Функция area() теперь неправильна — но компилятор молчит.// Потому что иерархия открытая.
В Scala это физически невозможно — sealed закрывает иерархию на уровне файла:
sealed trait Shape // никто не может расширить это из другого файлаcase class Circle(radius: Double) extends Shapecase class Rectangle(w: Double, h: Double) extends Shapedef area(s: Shape): Double = s match { case Circle(r) => Math.PI * r * r case Rectangle(w, h) => w * h // Кто-то добавит Triangle — компилятор выдаст ошибку здесь. // Не в рантайме. Не у пользователя. Здесь.}
Разница принципиальная: в TS exhaustiveness checking работает локально. В Scala — это инвариант всей программы.
Есть TC39 proposal на паттерн-матчинг — Stage 2 уже несколько лет. Реализация упирается в фундаментальный вопрос: что считать «типом» для сопоставления в динамическом языке без sealed traits? Каждый вариант дизайна ломает чьи-то ожидания.
8. «Гравитация языка»
Язык формирует стиль кода и культуру — не через запреты, а через то, что в нём естественно.
В Haskell, Clojure функциональный стиль — это единственный путь. Scala, будучи мультипарадигменным языком, дает выбор: можно писать в объектно-ориентированном стиле, как в Java, используя изменяемые переменные и наследование. Многие годы такие фреймворки, как Play и Akka(которая была ОЧЕНЬ популярна до скандала с лиценизиями), активно использовали эту возможность. Однако «гравитация языка» и современной экосистемы (Cats Effect, ZIO) направлена в другую сторону. Неизменяемые структуры данных, pattern matching и чистые функции в Scala реализованы настолько удобно и естественно, что путь наименьшего сопротивления ведёт именно к ним. Писать в ООП-стиле можно, но это требует сознательного усилия и всё чаще воспринимается как борьба с течением языка.
Поэтому, когда вы открываете чужой код на этих языках, вы видите FP-паттерны не потому что автор был дисциплинирован, а потому что язык просто не давал писать иначе.
В JS функциональный стиль — это осознанный выбор, который нужно делать каждый раз заново. Default — императивный подход, и он постоянно притягивает к себе. Это и есть гравитация языка: не злой умысел, не плохие разработчики — просто путь наименьшего сопротивления ведёт не туда.
Это влияет на кодовую базу, командную культуру и архитектуру. В JS/TS я потратил неприличное количество времени на объяснение коллегам, зачем нужны чистые функции, монады, и почему мутация может быть серьёзной проблемой. По сути, я просто плыл против течения. В Scala или Haskell этот разговор просто не нужен. Когда язык не даёт нативных инструментов для FP, функциональная культура не формируется органически. Вместо неё — процедурный код с парой .map() для приличия и // TODO: refactor в конце файла, которому уже три года.
Итог
JavaScript — мощный, гибкий, мультипарадигменный язык. Но функциональным языком он не является. Не потому что в нём нельзя писать функционально, а потому что он не был спроектирован для этого.
Каждое из ограничений — не баг и не просчёт. Это результат осознанных компромиссов между производительностью для типичных веб-задач, обратной совместимостью с гигантской экосистемой и простотой отладки в DevTools.
Понимать эти компромиссы важно — особенно когда кто-то делает выводы о функциональном программировании в целом, глядя только на JS. FP — это не про .map() и стрелочные функции. Это про другую модель вычислений, которую JS по объективным причинам поддерживает лишь частично.
ссылка на оригинал статьи https://habr.com/ru/articles/1025100/