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

@tailrec
def 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 в голову — получаем list2
val 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) => any
const 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 вы можете объявить обобщённый функтор:

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 и не готов мириться с процедурным хаосом.

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


7. Отсутствие pattern matching

Этот аргумент, на мой взгляд, относительно слабее остальных, но всё же: во многих функциональных языках паттерн-матчинг — первоклассная конструкция языка. В Scala это выглядит так:

sealed trait Shape
case class Circle(radius: Double)          extends Shape
case class Rectangle(w: Double, h: Double) extends Shape

def area(s: Shape): Double = s match {
  case Circle(r)       => Math.PI * r * r
  case Rectangle(w, h) => w * h
  // Добавите Triangle и забудете здесь — компилятор предупредит
}

Обратите внимание на несколько вещей одновременно: сопоставление по структуре, автоматическая деструктуризация, и sealed — явно выраженное намерение что иерархия закрыта: расширить её можно только в рамках того же файла. Сам по себе sealed не имеет прямого отношения к pattern matching, но они часто используются вместе для безопасности и удобства.

В JS есть switch/case, но его возможности ощутимо скромнее.

Есть TC39 proposal на паттерн-матчинг — Stage 1 уже несколько лет. Реализация упирается в фундаментальный вопрос: что считать «типом» для сопоставления в динамическом языке без 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 по объективным причинам поддерживает лишь частично.