Почему JS/TS — не функциональный язык (и почему это важно понимать)

от автора

Мотивацией для написания этого поста стали два года собеседований 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 есть нюансы:

  1. Потеря стектрейсов. При TCO хвостовой вызов заменяет текущий фрейм. В стеке остаётся только один фрейм вместо цепочки. Для отладки это катастрофа. В Scala такой проблемы нет, т.к. компилятор просто превращает хвостовую рекурсию в обычный while на этапе сборки.

  2. Совместимость со старыми API и отладка. В JS существуют устаревшие API вроде Function.caller, которые позволяют узнать, кто вызвал функцию. TCO разрушает эту информацию, так как стирает историю вызовов.

  3. Сложность реализации в 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 обработка ошибок (как и ifwhilefor) — нет.

// В 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/