DEV Community

Cover image for Why JS/TS Is Not a Functional Language (And Why It Matters)
Ivan
Ivan

Posted on

Why JS/TS Is Not a Functional Language (And Why It Matters)

This post grew out of two years of interviewing JS/TS engineers. I'm genuinely interested in programming languages — and functional programming in particular — so I always liked to weave paradigm discussions into technical interviews. Over time, I noticed a curious asymmetry.

When candidates talked about OOP, they were confident — but mostly at the conceptual level, rarely touching on how OOP is actually implemented in JavaScript specifically. With FP, the picture was different: less confidence overall, but when it came to criticism, the arguments were surprisingly concrete and consistent: "immutability is expensive in terms of memory", "recursion is unsafe because of stack overflow". What struck me was that these arguments almost always came from experience with JS — not Haskell, not Clojure, not Scala.

That detail matters. Every paradigm exists on two levels: the conceptual (the ideal model) and the implementational (how a specific language expresses that model). Judging FP by JS is roughly like judging OOP by bash scripts full of global variables.

At the same time, I kept hearing that JS is a functional language. Arguments ranged from "it has .map()" to more elaborate takes about pure functions and currying. That's what prompted this post: I want to share what I consider a functional language — and why JS doesn't qualify. Not just list missing features, but show why they're missing and what that means in real runtime.

Note: throughout this post, JS and TS are used interchangeably, except when discussing the type system — in those cases I'll specify TS explicitly.


1. Mutability by Default

In Haskell, you physically cannot mutate a variable. In Clojure, all core data structures are immutable out of the box. In JavaScript, it's the opposite.

const arr = [1, 2, 3];
arr.push(4);
console.log(arr); // [1, 2, 3, 4]

const user = { name: "Alice" };
user.name = "Bob"; // Works just fine
Enter fullscreen mode Exit fullscreen mode

const prevents reassignment of the reference — not mutation of the data. Think of it this way: you can't swap the box, but everything inside is fair game.

In Scala, val means something fundamentally different: immutability of the structure itself.

val list = List(1, 2, 3)
val newList = list :+ 4  // Returns a new list — list is untouched
println(list)    // List(1, 2, 3) — guaranteed
println(newList) // List(1, 2, 3, 4)

// case class is immutable by default
case class User(name: String)
val alice = User("Alice")
val bob = alice.copy(name = "Bob") // alice remains unchanged
Enter fullscreen mode Exit fullscreen mode

Why does this matter? Mutability by default breaks referential transparency and makes the guarantees that functional thinking relies on impossible to enforce. Yes, there's Object.freeze() and libraries like Immutable.js. But those are workarounds layered on top of a language designed with mutability in mind — not part of the language itself.

Why is it this way? JS was created in 1995 in 10 days as a browser scripting language. The "everything is mutable and lives on the heap" model was the simplest thing to implement and felt natural to developers coming from C or Java. Redesigning the memory model 30 years later without breaking the web is simply not possible.


2. No Tail Call Optimization (TCO)

TCO was included in the ES2015 standard. Today, only Safari supports it. V8 (Chrome, Node.js) — no. SpiderMonkey (Firefox) — no.

function factorial(n, acc = 1) {
  if (n <= 1) return acc;
  return factorial(n - 1, n * acc); // tail call — but TCO doesn't work
}
factorial(100000); // RangeError: Maximum call stack size exceeded
Enter fullscreen mode Exit fullscreen mode

Congratulations, you found a bug. Unfortunately, your user found it first.

In Scala, the same function with @tailrec doesn't just work — the compiler guarantees the optimization before runtime:

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) // Works correctly
// If a tail call is impossible — compile-time error,
// not a RangeError in production
Enter fullscreen mode Exit fullscreen mode

The key point: in Scala, you find out about the problem when you write the code. In JS — at runtime, from your users.

Why doesn't V8 implement TCO? It's just replacing a call with a jmp, right?

Technically — yes. But in a dynamic language with eval, Function.caller, and DevTools, things get complicated:

  • Loss of stack traces. With TCO, a tail call replaces the current frame. Only one frame remains in the stack instead of the full chain. For debugging, this is a disaster.
  • Compatibility with legacy APIs and debugging. JS has legacy APIs like Function.caller that let you inspect who called a function. TCO destroys that information by erasing call history. More importantly, it breaks standard stack traces (Error.stack). If an error occurs deep in the recursion, the developer sees only the last call — not the full chain that led there. Scala doesn't have this problem: the compiler simply turns tail recursion into a while loop at build time, so nothing unusual happens at runtime.
  • Complexity of JIT implementation. V8 uses multi-tier compilation (Ignition → Sparkplug → Maglev → TurboFan). TCO requires rethinking how deoptimized frames are generated and invalidated.

V8 engineers have openly stated that the cost of implementing TCO in the current engine architecture outweighs the benefit to the ecosystem. This is a deliberate trade-off, not a bug.


3. Errors Are Not Values

In functional programming, errors are just data. In JavaScript, errors are control flow explosions.

Consider a simple operation:

const data = JSON.parse(userInput);
data.user.name.toUpperCase(); // 💥 TypeError
Enter fullscreen mode Exit fullscreen mode

The problem isn't that the error happened. The problem is that the function lied. The signature (text: string) => any says: “I return a value.” In reality: “I might blow up your entire call stack.” This breaks referential transparency. You cannot replace JSON.parse(input) with its result without changing program behavior.

In functional languages, failure is encoded in the return type:

val result: Try[Json] = Try(parse(userInput))

val upperName = result.map(_.user.name.toUpperCase)
// Still a Try — Success or Failure
Enter fullscreen mode Exit fullscreen mode

Errors are values, they compose.

The Type System Blind Spot

TypeScript has no throws in its type system:

function getUser(id: string): User {
  // Looks safe.
  // Might throw. You don't know.
}
Enter fullscreen mode Exit fullscreen mode

Every function call is a potential landmine.

Statements vs Expressions

In FP, everything is a value. In JS, error handling(and if, while, for) isn't:

// Impossible in JS
const result = try {
  riskyOperation()
} catch (e) {
  handleError(e)
}
Enter fullscreen mode Exit fullscreen mode

try/catch is a statement, not an expression so you simply can't compose it, You have to break the flow.

The Workaround

Libraries like fp-ts or Effect reintroduce errors as data:

import { pipe } from 'fp-ts/function'
import * as E from 'fp-ts/Either'

// Wrapper function to handle potential error
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())
)
Enter fullscreen mode Exit fullscreen mode

But notice the key detail: you had to wrap JSON.parse manually. The language itself remains unaware of effects.

Why is it this way?
Exceptions in JavaScript are a control-flow mechanism, not a data model. They come from C++/Java of the 90s, where the goal was:

  • Avoid polluting return types
  • Handle “exceptional” situations without manual checks everywhere
  • Unwind the stack quickly and cheaply.

This design made sense in a scripting language for the browser: most failures were external (network errors, user actions) memory overhead mattered. Explicit error types would complicate simple code.

JavaScript inherited this model — and it stuck.


4. No Lazy or Persistent Collections

Let's look at peak memory usage and what actually happens during a typical JS method chain:

const result = hugeArray
  .filter(x => x > 0)   // [1] array A is created
                         //     in memory: hugeArray + A
  .map(x => x * 2)      // [2] array B is created
                         //     in memory: hugeArray + A + B
                         //     A is no longer needed — but GC hasn't arrived yet
  .filter(x => x < 100) // [3] array C is created
                         //     in memory: hugeArray + B + C (+ possibly A)
  .slice(0, 10);         // [4] result is created from 10 elements
                         //     C is no longer needed
Enter fullscreen mode Exit fullscreen mode

At the worst point — between steps 2 and 3 — hugeArray, A, and B all live in memory simultaneously. GC is not synchronous: an array is marked as a candidate for collection when nothing references it anymore, but it's actually freed later — on the engine's own schedule. On large data, this means a real memory spike in the middle of the chain, even if the final result is tiny.

Three full passes over the array just to get ten elements. Somewhere in Haskell, a compiler is weeping.

In Scala, .view turns the chain into a single element-by-element pipeline with no intermediate collections:

val result = hugeArray.view
  .filter(_ > 0)
  .map(_ * 2)
  .filter(_ < 100)
  .take(10)
  .toList
// Each element passes through all three operations exactly once.
// Once 10 elements are collected — processing stops entirely.
Enter fullscreen mode Exit fullscreen mode

What about generators?

JS does have generators, and technically they can achieve something similar:

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;
}
Enter fullscreen mode Exit fullscreen mode

Note: As of ES2025, JavaScript gained Iterator Helpers (arr.values().filter(...).take(10)), which do provide native lazy evaluation. This solves the syntax clutter, but not the deeper issue of persistent data structures discussed below.

Structural Sharing

// list1 — an existing list
val list1 = List(2, 3, 4)

// We prepend 1 to get list2
val list2 = 1 :: list1  // List(1, 2, 3, 4)

// Only one new node was created in memory — the head with value 1.
// The tail (2 -> 3 -> 4) was never copied — list2 simply points to list1.

// list2: [1] -> [2] -> [3] -> [4]
//                ↑
//         list1 starts here
//         both lists exist simultaneously,
//         sharing the same tail
Enter fullscreen mode Exit fullscreen mode

O(1) in both time and memory. For more complex structures (Scala Vector, Clojure Persistent Collections), structural sharing is implemented using prefix trees. When an element is "updated", only the path from root to leaf is copied — O(log n). The rest of the graph is shared by reference:

val v1 = Vector(1, 2, 3, 4, 5)
val v2 = v1.updated(2, 99) // "update" the third element
// v1 = Vector(1, 2, 3, 4, 5) — unchanged
// v2 = Vector(1, 2, 99, 4, 5)
// Copied: O(log n) nodes. Everything else — shared references.
Enter fullscreen mode Exit fullscreen mode

In JS, [...arr] is always a full copy. There are no persistent data structures with structural sharing out of the box.

Why doesn't JS do lazy collections by default?

  • Eager evaluation is cache-friendly. A JS array is a contiguous block of memory (FixedArray / Fast Elements in V8). Iterating over it is predictable for the CPU prefetcher. A lazy pipeline built on generators produces many small iterator calls, which destroys data locality.
  • JIT optimizations for arrays. V8 aggressively inlines Array.prototype methods into compiled code. No such optimizations exist for lazy chains.
  • Structural sharing vs. cache locality. Persistent structures use wide-branching trees. Accessing an element requires several pointer dereferences. On an array, it's a single offset. For UI rendering, where data is read linearly, copy-on-write arrays can actually be faster than persistent structures — despite the allocations — because of better cache utilization.

The language is optimized for the mainstream use case, not for big data processing.


5. No Syntactic Support for Monads

Technically, Promise is a monad (almost). Array with .flatMap() is also a monad. JS allows you to express monadic patterns. But there's a vast difference between "allows" and "supports".

Scala has for-comprehensions:

val result = for {
  user    <- findUser(id)      // Option[User]
  address <- user.address      // Option[Address]
  city    <- address.city      // Option[String]
} yield city
Enter fullscreen mode Exit fullscreen mode

In JS, the same thing means nested .then() or .flatMap() chains. The language has no idea you're working with a monad — and offers you no help.

Why is there no monadic sugar in JS? Because monads are an abstraction over higher-kinded types (HKT). And those don't exist (see section 5). Promise is the only monad built into the language, and it has its own special syntax — async/await. But that's an ad-hoc solution, not a general mechanism.


6. No Higher-Kinded Types (HKT)

In Haskell or Scala, you can write a generic Functor:

trait Functor[F[_]] {
  def map[A, B](fa: F[A])(f: A => B): F[B]
}
Enter fullscreen mode Exit fullscreen mode

This means: "For any type F that takes one parameter, I can define how map works." Whether it's List, Option, or Future — one abstraction covers all of them.

TypeScript can't do this. The fp-ts library is forced to emulate HKT through a hand-maintained type registry:

// Step 1: the registry — a dictionary of "string → real type"
// This is the only way to teach TS that 'Array' means Array<A>
interface URItoKind<A> {
  readonly Array: Array<A>
  readonly Option: Option<A>
  // every new type must be registered here manually
}

// Step 2: URIS is just the union of all registered string keys
// type URIS = 'Array' | 'Option' | ...
type URIS = keyof URItoKind<unknown>

// Step 3: Kind is an indexed access type (a lookup into the registry)
// Kind<'Array', number>  → URItoKind<number>['Array']  → Array<number>
// Kind<'Option', string> → URItoKind<string>['Option'] → Option<string>
// This is where the string gets turned back into a real generic type
type Kind<F extends URIS, A> = URItoKind<A>[F]

// Step 4: now we can write a "generic" Functor
// The quotes are intentional — this isn't real type constructor polymorphism,
// it's an emulation through a table of strings
interface Functor<F extends URIS> {
  map<A, B>(fa: Kind<F, A>, f: (a: A) => B): Kind<F, B>
}
Enter fullscreen mode Exit fullscreen mode

This isn't a language feature. It's a fight against limitations — a fight the authors of fp-ts wage with remarkable genius and tenacity. Genuinely impressive. The diagnosis, however, remains unchanged.

Why doesn't TypeScript add HKT? TS is a superset of JavaScript with structural typing. HKT requires kind-polymorphism support in the compiler. Retrofitting that into a structural type system would require a fundamental redesign of the type inference and compatibility checking algorithms. The TS team has discussed this and concluded that the cost is too high relative to the practical benefit for a typical TS project. Another trade-off.

Worth a special mention: Effect TS — a modern library that sidesteps the HKT problem in a completely different way. Instead of emulating it through a URI registry, it builds its own effect system around a single central type: Effect<A, E, R>, which encodes success, failure, and dependencies in one place. It's essentially a full effect system in the spirit of ZIO from Scala — with structured concurrency, context-based dependency injection, and composable error handling. Effect doesn't pretend to solve HKT in the general case, but for the task of "writing reliable, composable code with managed effects" it offers a more honest and practical answer than fp-ts. It's telling that it's gaining popularity specifically among developers who come to TS from Scala or Haskell and aren't willing to live with procedural chaos.


7. No Pattern Matching

In functional languages, pattern matching is a first-class construct with exhaustiveness checking. In Scala, the compiler will warn you if you forget to handle a new subtype:

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
  // Add Triangle and forget to handle it here —
  // the compiler will catch it before the code ships
}
Enter fullscreen mode Exit fullscreen mode

JS has switch/case, but it doesn't work with data structures, doesn't guarantee exhaustiveness, doesn't destructure automatically, and requires explicit break — a classic source of bugs.

TypeScript has discriminated unions, and at first glance they look like a solution:

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
  }
}
Enter fullscreen mode Exit fullscreen mode

TS will check exhaustiveness — but only within the file. Nothing stops someone in another file from extending the type:

// Different file, different developer, three months later
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number } // Added!

// area() is now incorrect — but the compiler says nothing.
// Because the hierarchy is open.
Enter fullscreen mode Exit fullscreen mode

In Scala, this is physically impossible — sealed closes the hierarchy at the file level:

sealed trait Shape // no one can extend this from another file
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
  // Someone adds Triangle — the compiler errors here.
  // Not at runtime. Not in production. Here.
}
Enter fullscreen mode Exit fullscreen mode

The difference is fundamental: in TS, exhaustiveness checking is local. In Scala — it's a program-wide invariant.

There is a TC39 proposal for pattern matching — it's been at Stage 2 for years. The implementation keeps hitting the same wall: what counts as a "type" for matching in a dynamic language with no sealed traits? Every design choice breaks someone's expectations.


8. The Gravity of a Language

A language shapes code style and culture — not through prohibition, but through what feels natural.

In Haskell and Clojure, functional style is the only path. In Scala — a multi-paradigm language — there's technically a choice: you can write in an object-oriented style, with mutable variables and inheritance, just like in Java. For many years, frameworks like Play and Akka leaned heavily on this. But the gravitational pull of the language and its modern ecosystem (Cats Effect, ZIO) points in the opposite direction. Immutable data structures, pattern matching, and pure functions in Scala are implemented so naturally that the path of least resistance leads straight to them. Writing in OOP style is possible — but it requires conscious effort and increasingly feels like swimming against the current.

So when you open someone else's Scala or Haskell code, you see FP patterns not because the author was disciplined, but because the language didn't really let them do otherwise.

In JS, functional style is a deliberate choice you have to make over and over again. The default is imperative, and it pulls at you constantly. That's the gravity of the language: not malice, not bad developers — just the path of least resistance leading somewhere else.

This affects the codebase, team culture, and architecture. In JS/TS, I've spent an unreasonable amount of time explaining to colleagues why pure functions matter and why mutation may be a problem and why monads are cool. I was essentially swimming upstream the whole time. When your team is using functional languages, that conversation simply doesn't happen. When a language doesn't provide native tools for FP, functional culture doesn't form organically. What grows in its place — procedural code with a couple of .map() calls for respectability, and a // TODO: refactor at the bottom of a file that's been there for three years.


Conclusion

JavaScript is a powerful, flexible, multi-paradigm language. But it is not a functional language. Not because you can't write functionally in it — you can. But because it was never designed for that.

Every limitation discussed here is not a bug or an oversight. Each one is the result of deliberate trade-offs between performance for typical web tasks, backward compatibility with a massive ecosystem, and ease of debugging in DevTools.

Understanding these trade-offs matters — especially when someone draws conclusions about functional programming in general by looking only at JS. FP is not about .map() and arrow functions. It's about a different model of computation — one that JS, for entirely objective reasons, only partially supports.

Top comments (0)