Skip to main content

JavaScript Functional Programming: Pure Functions, Immutability, Composition

JavaScript Functional Programming: Pure Functions, Immutability, Composition

🗓️  Jul 21, 2026

Post #13’s easiest tests to write were the simplest, most predictable functions — call them with an input, check the output, done. That predictability is not an accident. It is a direct consequence of a property called purity, and understanding it precisely explains why some functions in this series have been trivial to test while others (anything touching this.#tasks directly, anything calling fetch) required considerably more setup.

This post covers functional programming as a genuinely practical discipline, not an academic exercise: pure functions, immutability, and a gotcha that catches nearly every JavaScript developer at least once — several of the array methods covered back in Post #4 silently mutate the original array, while others do not, and the split between them is not intuitive at all.


Pure Functions: Same Input, Same Output, No Side Effects

A pure function has exactly two properties: given the same input, it always produces the same output, and it does not modify anything outside itself (no mutating an argument, no changing a variable outside its own scope, no network calls, no console.log).

// Pure — always returns the same result for the same input, touches nothing outside itself
function double(x) {
    return x * 2;
}
// Impure — depends on external state, not just its own input
let multiplier = 2;
function multiply(x) {
    return x * multiplier; // result depends on something OUTSIDE the function's own parameters
}
// Impure — has a side effect: it mutates the array passed into it
function addTaskImpure(tasks, description) {
    tasks.push({ description, completed: false }); // modifies the ORIGINAL array directly
    return tasks;
}
// Pure — returns a genuinely NEW array, the original input is never touched
function addTaskPure(tasks, description) {
    return [...tasks, { description, completed: false }];
}

Why This Directly Explains Post #13’s Testing Experience

// Trivial to test — pure function, no setup required beyond the input itself
test("double(5) returns 10", () => {
    expect(double(5)).toBe(10);
});
// Genuinely harder to test correctly — impure, mutates its argument
test("addTaskImpure adds a task", () => {
    const tasks = [];
    addTaskImpure(tasks, "Test");
    expect(tasks).toHaveLength(1); // must check the ORIGINAL array, not just the return value
    // and if 'tasks' were reused across multiple tests without being reset, 
    // leftover mutations from an earlier test could silently corrupt this one
});

This is precisely why Post #13 emphasized beforeEach creating a genuinely fresh instance before every test — impure functions and mutable shared state are exactly what makes that discipline necessary in the first place. Pure functions sidestep the entire problem: there is no shared state to accidentally leak between tests, because a pure function never touches anything beyond what you explicitly pass into it.


⚠️ The Array Mutation Gotcha: Not Every Array Method Is “Safe”

Post #4 covered map, filter, and reduce — all genuinely pure with respect to their input array, always returning a new array or value, never touching the original. Several other, equally common array methods do not follow this pattern, and the split is not obvious from the method names alone:

const numbers = [3, 1, 4, 1, 5];

// NON-MUTATING — always return a new array, original is untouched
numbers.map((n) => n * 2);
numbers.filter((n) => n > 2);
numbers.slice(1, 3);
numbers.concat([9, 9]);

console.log(numbers); // [3, 1, 4, 1, 5] — completely unchanged after all four calls above
// MUTATING — modify the ORIGINAL array directly, in place
numbers.push(9);        // adds to the original
numbers.pop();            // removes from the original
numbers.sort();             // sorts the original IN PLACE — genuinely surprises many developers
numbers.reverse();            // reverses the original in place
numbers.splice(1, 1);           // removes/inserts directly into the original

console.log(numbers); // has genuinely changed after these calls
Method Mutates the original?
map, filter, reduce, slice, concat, find, some, every No
push, pop, shift, unshift, splice, sort, reverse, fill Yes

.sort() mutating the original array specifically is the single most common surprise here — it looks, by name, like it should behave the same way map/filter do (take an array, produce a sorted result), but it directly rearranges the original array’s contents and returns a reference to that same, now-modified array.

The ES2023 Fix: Non-Mutating Alternatives

Recent JavaScript (ES2023+) added genuinely non-mutating equivalents for exactly the methods that previously only had mutating versions:

const numbers = [3, 1, 4, 1, 5];

const sorted = numbers.toSorted();          // like sort(), but returns a NEW array — original untouched
const reversed = numbers.toReversed();         // like reverse(), non-mutating
const spliced = numbers.toSpliced(1, 1);          // like splice(), non-mutating
const updated = numbers.with(0, 100);               // like numbers[0] = 100, but returns a NEW array

console.log(numbers); // [3, 1, 4, 1, 5] — genuinely unchanged by any of the above

The practical guidance for this series, and for new code generally: prefer toSorted(), toReversed(), and toSpliced() over their mutating counterparts whenever you do not specifically need in-place mutation — which, in most application code working with data that flows through functions and gets displayed or compared elsewhere, is the large majority of cases.


Immutability in Practice

// Mutating update — the ORIGINAL task object changes
function completeTaskMutating(task) {
    task.completed = true;
    return task;
}

// Immutable update — a NEW object is returned, the original is untouched
function completeTaskImmutable(task) {
    return { ...task, completed: true };
}
const original = { description: "Learn JS", completed: false };
const updated = completeTaskImmutable(original);

console.log(original.completed); // false — genuinely unchanged
console.log(updated.completed);   // true — the new object reflects the change

Immutable updates — building a new value rather than modifying an existing one — trade a small amount of memory and CPU overhead (creating a new object/array instead of modifying one in place) for a considerable reduction in an entire category of bugs: code elsewhere that still holds a reference to the “original” data can no longer be surprised by it silently changing underneath it, exactly the shallow-copy-versus-shared-reference concern covered back in Post #4, now applied as a deliberate coding discipline rather than an occasional gotcha to watch for.


Function Composition: Building Bigger Functions From Small Ones

const trim = (str) => str.trim();
const lowercase = (str) => str.toLowerCase();
const removeSpaces = (str) => str.replace(/\s+/g, "-");

function compose(...fns) {
    return (input) => fns.reduce((acc, fn) => fn(acc), input);
}

const slugify = compose(trim, lowercase, removeSpaces);

console.log(slugify("  Learn JavaScript  ")); // "learn-javascript"

compose takes any number of single-argument functions and returns a new function that runs the input through each of them in sequence, each one’s output becoming the next one’s input — directly using reduce from Post #4 to implement this chaining. Each individual piece (trim, lowercase, removeSpaces) is small, pure, and trivially testable on its own, exactly the Post #13 testing advantage covered at the start of this post — and slugify, the composed result, inherits that same testability by construction, since it is built entirely from pieces that are each independently verified.


Currying: A Specific, Useful Application of Closures

// Regular function
function add(a, b) {
    return a + b;
}

// Curried — returns a function that takes the next argument, directly using closures from Post #6
function addCurried(a) {
    return (b) => a + b;
}

const add5 = addCurried(5);
console.log(add5(3)); // 8
console.log(add5(10)); // 15

Currying transforms a function taking multiple arguments into a sequence of functions each taking one — genuinely just an application of the closures covered in Post #6, applied to a specific, recurring pattern: creating specialized, reusable functions from a general one by “locking in” some arguments ahead of time.

const isHighPriority = (task) => task.priority === "high";
const highPriorityTasks = tasks.filter(isHighPriority);

// A curried "filter by priority" generator
const filterByPriority = (priority) => (task) => task.priority === priority;
const highPriorityTasks2 = tasks.filter(filterByPriority("high"));
const mediumPriorityTasks = tasks.filter(filterByPriority("medium"));

filterByPriority("high") returns a new, specialized function ready to pass directly to .filter() — reusable for any priority level without writing a separate, near-identical function for each one.


Applying This to the Task Tracker

function completeTask(tasks, index) {
    return tasks.map((task, i) =>
        i === index ? { ...task, completed: true } : task
    );
}

function sortTasksByPriority(tasks) {
    const priorityOrder = { high: 0, medium: 1, low: 2 };
    return tasks.toSorted(
        (a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]
    );
}

function filterIncompleteTasks(tasks) {
    return tasks.filter((task) => !task.completed);
}

let tasks = [
    { description: "Learn JS", priority: "medium", completed: false },
    { description: "Build a project", priority: "high", completed: false },
    { description: "Review PRs", priority: "low", completed: true },
];

tasks = completeTask(tasks, 0);
const sorted = sortTasksByPriority(tasks);
const incomplete = filterIncompleteTasks(sorted);

console.log(incomplete);
// Original 'tasks' array structure never mutated in place —
// every function here returns a new result instead

Every function here is pure: given the same tasks array and the same arguments, each always produces the same result, and none of them mutates the array or objects passed into it — sortTasksByPriority specifically uses toSorted() rather than sort(), precisely avoiding the mutation gotcha covered at length above. This is directly, immediately more testable, following Post #13’s exact patterns, than an equivalent set of functions relying on in-place mutation of this.#tasks inside a class.


Real-World Use Cases

State management in UI frameworks: Virtually every modern JavaScript UI framework’s recommended pattern for updating state relies on immutability — returning new objects/arrays rather than mutating existing ones — specifically because it makes detecting “did anything actually change” fast and reliable (a simple reference comparison, rather than a deep, expensive comparison of every property).

Predictable, testable business logic: Pure functions for calculations, validations, and transformations — exactly the pattern demonstrated with the task tracker above — are dramatically easier to unit test in isolation, directly building on Post #13’s testing coverage.

Safe data transformation pipelines: Function composition, chaining small, pure transformation functions together, is the standard shape of data-processing code that needs to remain readable and independently verifiable step by step.

Avoiding “spooky action at a distance” bugs: Code that passes an object or array to a function, and later discovers that function silently modified it, is one of the most common and hardest-to-trace bug categories in real applications — immutability, applied consistently, eliminates this entire category by construction.


Common Mistakes and Gotchas

⚠️ Mistake 1: Assuming .sort(), .reverse(), or .splice() are non-mutating like .map()/.filter() Covered at length above — this is the single most important, most commonly-missed gotcha in this post. Use toSorted(), toReversed(), and toSpliced() when you specifically want the non-mutating behavior.

⚠️ Mistake 2: Writing a function that looks pure but has a hidden side effect

function calculateTotal(items) {
    console.log("Calculating..."); // technically a side effect (console output)!
    return items.reduce((sum, item) => sum + item.price, 0);
}

Strictly, any observable effect outside the return value — including logging — makes a function impure, even if it does not mutate any data. In practice, most developers treat logging as an acceptable exception for debugging purposes; genuinely important, worth being deliberate about, is avoiding mutation and external state dependency specifically.

⚠️ Mistake 3: Over-applying functional style where it genuinely hurts readability

// Needlessly dense — hard to read even though it's "purely functional"
const result = data.filter(x => x.active).map(x => x.value).reduce((a, b) => a + b, 0) / data.filter(x => x.active).length;

Purity and immutability are means to genuinely valuable ends (testability, predictability) — not goals to pursue at the cost of a colleague (or future you) being able to read the code six months later. A version with clearly-named intermediate variables is often the better real-world choice, even if it is “less functional” in a strict sense.

⚠️ Mistake 4: Deeply nesting spread operators trying to achieve immutability with nested objects

const updated = {
    ...state,
    user: {
        ...state.user,
        address: {
            ...state.user.address,
            city: "Denver",
        },
    },
};

This correctly achieves immutability but becomes genuinely hard to read past two or three levels of nesting — for deeply nested state, a dedicated immutability-helper library becomes worth considering rather than hand-writing increasingly deep nested spreads.

⚠️ Mistake 5: Forgetting that currying and composition add a layer of indirection that not every reader will immediately follow Highly composed, heavily curried code can be genuinely elegant to someone fluent in the style, and genuinely opaque to someone who is not — calibrate how heavily to lean on these patterns based on your team’s actual familiarity with them, not purely on personal preference.


Performance Note

Creating new objects and arrays instead of mutating existing ones — the immutability discipline covered throughout this post — carries real, measurable overhead compared to in-place mutation, particularly for very large data structures updated frequently. For the overwhelming majority of application code, this overhead is negligible next to the debugging-time and correctness benefits immutability provides; it becomes a genuine, worth-measuring concern specifically in performance-critical code processing large datasets repeatedly — exactly the kind of tradeoff Post #15’s dedicated performance coverage addresses with real measurement rather than assumption.


Quick Reference

// Pure function — same input, same output, no side effects
function pure(x) { return x * 2; }

// Impure — depends on/modifies external state
let count = 0;
function impure() { count++; return count; }

// Non-mutating array methods (safe by default)
arr.map(fn); arr.filter(fn); arr.reduce(fn, init); arr.slice(a, b); arr.concat(other);

// MUTATING array methods (modify the original!)
arr.push(x); arr.pop(); arr.sort(fn); arr.reverse(); arr.splice(i, n);

// ES2023+ non-mutating alternatives
arr.toSorted(fn); arr.toReversed(); arr.toSpliced(i, n); arr.with(i, value);

// Immutable object update
const updated = { ...original, key: newValue };

// Function composition
const compose = (...fns) => (input) => fns.reduce((acc, fn) => fn(acc), input);

// Currying
const curriedAdd = (a) => (b) => a + b;

Exercises

Exercise 1 — Direct application Write a pure function incrementPriority(task) that returns a new task object with its priority bumped up one level ("low""medium""high", "high" stays "high"), without mutating the original task object.

Exercise 2 — Slight variation Take a function you wrote in an earlier post’s exercise that used .sort() or .splice() directly, and rewrite it using .toSorted() or .toSpliced() instead, confirming the original array is genuinely unaffected afterward.

Exercise 3 — Real-world combination Using compose from this post, build a formatTaskForDisplay function chaining together three small pure functions: trimming the description, capitalizing its first letter, and appending the priority in parentheses — each piece tested independently, exactly following Post #13’s patterns.

Exercise 4 — Open-ended challenge Deliberately write a function that mutates an array passed into it, write a test for it (following Post #13), then rewrite the function to be pure instead, and rewrite the test to match — compare how much simpler the pure version’s test setup becomes.


FAQ

Q: Should all my code be written in a strictly functional style? A: No — JavaScript is a multi-paradigm language, and this series has used classes, closures, and imperative loops throughout where each was the clearest tool for the job. Functional techniques (purity, immutability, composition) are genuinely valuable specifically where testability and predictability matter most — business logic, data transformations — not a universal mandate for every line of code.

Q: Is toSorted() supported everywhere, or do I need to worry about compatibility? A: toSorted() and its ES2023 siblings are supported in all current major browsers and Node.js versions as of this series. For code that must run in genuinely older environments, the [...array].sort() copy-then-mutate pattern remains the compatible fallback.

Q: Why does mutating an array or object matter if I’m the only one using that data? A: Even in single-developer code, mutation bugs are common — passing the same array to two different functions, expecting both to see the “original” data, only to have one silently change what the other sees. The discipline pays off even without a team involved, simply because most real applications pass data through many functions over time.

Q: Is currying actually used often in real JavaScript code, or is it mostly academic? A: It appears constantly, often without being explicitly labeled “currying” — any function that returns a specialized function based on some initial configuration (exactly like filterByPriority("high") in this post) is using the same underlying pattern, whether or not the code’s author thinks of it in those specific terms.


Summary and Next Steps

You now understand precisely why some functions in this series have been trivially easy to test while others required more setup — purity, or the lack of it. You know the specific, commonly-missed array mutation gotcha (sort, reverse, splice mutate; map, filter, slice do not) and the modern ES2023 non-mutating alternatives that solve it directly. You can compose small, pure functions into larger ones, and use currying to build specialized functions from general ones.

Your next step: Complete Exercise 2 — auditing an earlier exercise for hidden .sort() or .splice() mutation and fixing it with the ES2023 alternatives — since finding this specific, easy-to-miss bug in code you already wrote is the clearest possible confirmation that this lesson has genuinely transferred from reading to practice.

The next post turns to performance directly: profiling real JavaScript code, understanding V8’s optimization behavior, and measuring — rather than assuming — where the actual bottlenecks in a program are.


Code tested with Node.js 22 LTS. Last updated: July 2026.

Share This Post

Enjoyed this article?

Get notified when we publish new guides and tutorials. No spam, unsubscribe anytime.

📬 Newsletter coming soon — stay tuned!

The information contained on this blog is for academic and educational purposes only. Unauthorized use and/or duplication of this material without express and written permission from this site’s author and/or owner is strictly prohibited. The materials (images, logos, content) contained in this web site are protected by applicable copyright and trademark law.