Immutability & Pure Functions
What is Immutability?
Immutability is one of the most important concepts in modern JavaScript programming. An immutable value is one that, once created, can never be changed. Instead of modifying existing data, you create new data structures that reflect the desired changes while leaving the original untouched. This might sound inefficient at first -- why create a whole new object when you only need to change one property? But immutability provides profound benefits for code reliability, predictability, debugging, and performance optimization. It is a foundational principle of functional programming and has become increasingly important in frameworks like React, Redux, and modern state management libraries.
In JavaScript, primitive values (strings, numbers, booleans, null, undefined, Symbol, and BigInt) are already immutable by nature. When you write let x = 5; x = 10;, you are not changing the value 5 -- you are pointing the variable x to a completely new value 10. The value 5 itself remains unchanged in memory. Strings work the same way: calling str.toUpperCase() does not modify the original string but returns a brand-new string. The challenge with immutability arises with reference types -- objects and arrays -- which are mutable by default in JavaScript. Understanding this distinction is crucial for writing reliable code.
Mutable vs Immutable Data
To truly appreciate immutability, you need to understand how mutation works in JavaScript and why it causes problems. When you create an object or array and assign it to a variable, that variable holds a reference (a memory address) to the actual data. When you assign that variable to another variable, both variables point to the exact same data in memory. This means modifying the data through one variable affects all variables that reference it.
Example: Mutable Data -- The Shared Reference Problem
// Objects are mutable by default
const user = { name: 'Alice', age: 30, role: 'developer' };
const admin = user; // Both variables point to the SAME object
admin.role = 'admin';
console.log(user.role); // "admin" -- user was changed too!
console.log(user === admin); // true -- they are the same object
// Arrays are also mutable by default
const original = [1, 2, 3, 4, 5];
const copy = original; // Both point to the SAME array
copy.push(6);
console.log(original); // [1, 2, 3, 4, 5, 6] -- original was modified!
console.log(original === copy); // true -- same array
// Nested objects compound the problem
const config = {
database: { host: 'localhost', port: 5432 },
cache: { enabled: true, ttl: 3600 },
};
const testConfig = config;
testConfig.database.host = 'test-server';
console.log(config.database.host); // "test-server" -- production config modified!
const keyword does NOT make values immutable. It only prevents the variable from being reassigned to a different value. A const object or array can still have its properties or elements modified freely. Writing const user = { name: 'Alice' }; user.name = 'Bob'; works perfectly fine. The const keyword guarantees that user will always point to the same object, but it says nothing about whether that object's contents can change. This is one of the most common misconceptions in JavaScript.Problems with Mutation
Mutation creates several categories of bugs that are notoriously difficult to track down. The core issue is that when data can change from anywhere in your program, you lose the ability to reason about your code locally. You must understand every place in the entire codebase that might access and modify a particular piece of data.
Example: Real-World Mutation Bugs
// Bug 1: Function unexpectedly modifies its input
function addDiscount(products, discountPercent) {
for (let i = 0; i < products.length; i++) {
products[i].price *= (1 - discountPercent / 100);
}
return products;
}
const catalog = [
{ name: 'Laptop', price: 1000 },
{ name: 'Phone', price: 500 },
];
const discounted = addDiscount(catalog, 10);
console.log(catalog[0].price); // 900 -- original catalog was mutated!
// Every call to addDiscount keeps reducing prices further
// Bug 2: Shared state between components
const appState = {
user: { name: 'Alice', preferences: { theme: 'dark' } },
notifications: [],
};
function UserProfile(state) {
const userData = state.user;
userData.lastViewed = new Date(); // Mutates shared state!
return userData;
}
function NotificationPanel(state) {
// This component now sees lastViewed on user -- unexpected!
console.log(state.user.lastViewed); // Date object -- where did this come from?
}
// Bug 3: Race conditions with async operations
const sharedData = { count: 0 };
async function incrementAsync() {
const current = sharedData.count;
await new Promise(resolve => setTimeout(resolve, 100));
sharedData.count = current + 1; // May overwrite another increment!
}
// Both read count as 0, both write 1 -- expected 2 but got 1
Promise.all([incrementAsync(), incrementAsync()])
.then(() => console.log(sharedData.count)); // 1, not 2!
Immutable Patterns for Arrays
JavaScript provides several built-in methods and operators that create new arrays instead of modifying existing ones. These are the foundation of immutable array operations. The key is to always use methods that return new arrays (like map, filter, concat, slice) and avoid methods that mutate in place (like push, pop, splice, sort, reverse).
Example: Immutable Array Operations with Spread Operator
const numbers = [1, 2, 3, 4, 5];
// Adding elements -- use spread instead of push/unshift
const withSix = [...numbers, 6]; // [1, 2, 3, 4, 5, 6]
const withZero = [0, ...numbers]; // [0, 1, 2, 3, 4, 5]
const withInserted = [...numbers.slice(0, 2), 99, ...numbers.slice(2)];
// [1, 2, 99, 3, 4, 5]
console.log(numbers); // [1, 2, 3, 4, 5] -- unchanged!
// Removing elements -- use filter instead of splice
const withoutThree = numbers.filter(n => n !== 3); // [1, 2, 4, 5]
const withoutFirst = numbers.slice(1); // [2, 3, 4, 5]
const withoutLast = numbers.slice(0, -1); // [1, 2, 3, 4]
const withoutIndex2 = [...numbers.slice(0, 2), ...numbers.slice(3)];
// [1, 2, 4, 5]
console.log(numbers); // [1, 2, 3, 4, 5] -- still unchanged!
// Combining arrays -- use spread or concat instead of push
const moreNumbers = [6, 7, 8];
const combined = [...numbers, ...moreNumbers]; // [1, 2, 3, 4, 5, 6, 7, 8]
const alsoCombined = numbers.concat(moreNumbers); // same result
// Replacing elements at specific index
const index = 2;
const replaced = [...numbers.slice(0, index), 99, ...numbers.slice(index + 1)];
// [1, 2, 99, 4, 5]
console.log(numbers); // [1, 2, 3, 4, 5] -- always unchanged!
Example: Immutable Transformations with map, filter, and reduce
const users = [
{ id: 1, name: 'Alice', active: true },
{ id: 2, name: 'Bob', active: false },
{ id: 3, name: 'Charlie', active: true },
{ id: 4, name: 'Diana', active: false },
];
// Transform each element -- map always returns a new array
const names = users.map(user => user.name);
// ['Alice', 'Bob', 'Charlie', 'Diana']
// Filter elements -- filter always returns a new array
const activeUsers = users.filter(user => user.active);
// [{ id: 1, ... }, { id: 3, ... }]
// Update a specific item immutably
const updatedUsers = users.map(user =>
user.id === 2
? { ...user, active: true } // Create new object for the updated user
: user // Keep reference to unchanged users
);
console.log(users[1].active); // false -- original unchanged
console.log(updatedUsers[1].active); // true -- new array has updated user
// Chain operations for complex transformations
const result = users
.filter(user => user.active)
.map(user => ({ ...user, name: user.name.toUpperCase() }))
.reduce((acc, user) => ({ ...acc, [user.id]: user }), {});
// { 1: { id: 1, name: 'ALICE', active: true }, 3: { id: 3, name: 'CHARLIE', active: true } }
sort() and reverse() methods -- they mutate the original array AND return a reference to it. To sort immutably, always spread into a new array first: const sorted = [...numbers].sort((a, b) => a - b). In newer JavaScript engines (ES2023+), you can also use toSorted(), toReversed(), and toSpliced() which return new arrays without mutating the original. These are purpose-built for immutable array operations.Immutable Patterns for Objects
Objects in JavaScript are mutable by default, but the spread operator and Object.assign allow you to create modified copies without touching the original. The spread operator (...) creates a shallow copy of an object, and you can override specific properties by listing them after the spread. This is the most common pattern for immutable object updates in modern JavaScript.
Example: Immutable Object Updates with Spread
const user = {
id: 1,
name: 'Alice',
email: 'alice@example.com',
age: 30,
role: 'developer',
};
// Update a single property -- spread then override
const updatedUser = { ...user, age: 31 };
console.log(user.age); // 30 -- original unchanged
console.log(updatedUser.age); // 31 -- new object with updated age
// Update multiple properties
const promotedUser = { ...user, role: 'senior developer', age: 31 };
// Add new properties
const enrichedUser = { ...user, department: 'Engineering', startDate: '2020-01-15' };
// Remove a property using destructuring
const { email, ...userWithoutEmail } = user;
console.log(userWithoutEmail);
// { id: 1, name: 'Alice', age: 30, role: 'developer' }
// Computed property names for dynamic updates
const field = 'name';
const newValue = 'Alice Smith';
const dynamicUpdate = { ...user, [field]: newValue };
console.log(dynamicUpdate.name); // "Alice Smith"
console.log(user); // completely unchanged -- all properties original
Example: Object.assign for Immutable Updates
const defaults = {
theme: 'light',
language: 'en',
notifications: true,
fontSize: 16,
};
// Object.assign creates a new object when target is {}
const userPrefs = Object.assign({}, defaults, { theme: 'dark', fontSize: 18 });
console.log(defaults.theme); // "light" -- unchanged
console.log(userPrefs.theme); // "dark" -- new object
// Merging multiple objects immutably
const base = { a: 1, b: 2 };
const overrides = { b: 3, c: 4 };
const extras = { d: 5 };
const merged = Object.assign({}, base, overrides, extras);
// { a: 1, b: 3, c: 4, d: 5 }
console.log(base); // { a: 1, b: 2 } -- unchanged
// WARNING: Object.assign with a non-empty target MUTATES the target!
const mutableMerge = Object.assign(base, overrides); // Mutates base!
console.log(base); // { a: 1, b: 3, c: 4 } -- base was modified!
// ALWAYS use an empty object {} as the first argument for immutability
const safeMerge = Object.assign({}, base, overrides); // Safe!
Object.assign perform only a shallow copy. They copy the top-level properties, but if any property value is an object or array, the copy will contain a reference to the same nested object. This means modifying a nested property on the copy will affect the original. For deep immutability, you need additional techniques like deep cloning or specialized immutable update patterns, which we will cover in the following sections.Object.freeze -- Shallow Immutability
Object.freeze() is a built-in JavaScript method that prevents modification of an object's existing properties. Once frozen, you cannot add new properties, remove existing properties, or change the values of existing properties. Attempts to modify a frozen object silently fail in normal mode and throw a TypeError in strict mode. However, Object.freeze is shallow -- it only freezes the top-level properties. Nested objects within a frozen object remain mutable.
Example: Object.freeze Behavior
'use strict';
const config = Object.freeze({
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
});
// All of these will throw TypeError in strict mode
// config.apiUrl = 'https://other.com'; // TypeError
// config.newProp = 'value'; // TypeError
// delete config.timeout; // TypeError
console.log(Object.isFrozen(config)); // true
// But freeze is SHALLOW -- nested objects are still mutable!
const appConfig = Object.freeze({
server: { host: 'localhost', port: 3000 },
database: { host: 'localhost', port: 5432 },
});
appConfig.server.port = 8080; // This WORKS -- nested object is not frozen!
console.log(appConfig.server.port); // 8080 -- modified!
// The top-level reference is frozen, but the object it points to is not
// appConfig.server = {}; // TypeError -- cannot reassign top-level property
appConfig.server.host = 'production'; // Works! Nested object is mutable
// Freezing arrays
const frozenArray = Object.freeze([1, 2, 3, 4, 5]);
// frozenArray.push(6); // TypeError
// frozenArray[0] = 99; // TypeError
// frozenArray.length = 0; // TypeError
console.log(frozenArray); // [1, 2, 3, 4, 5] -- truly frozen at top level
Deep Freeze
Since Object.freeze is shallow, you need a recursive approach to freeze an entire object tree. A deep freeze function walks through all properties of an object and freezes each nested object it finds. This ensures that no level of the data structure can be modified.
Example: Implementing Deep Freeze
function deepFreeze(obj) {
// Get all property names (including non-enumerable ones)
const propNames = Object.getOwnPropertyNames(obj);
// Freeze each nested object before freezing the parent
for (const name of propNames) {
const value = obj[name];
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
deepFreeze(value);
}
}
return Object.freeze(obj);
}
// Usage
const config = deepFreeze({
server: {
host: 'localhost',
port: 3000,
ssl: { enabled: true, cert: '/path/to/cert' },
},
database: {
host: 'localhost',
port: 5432,
credentials: { user: 'admin', password: 'secret' },
},
features: ['auth', 'logging', 'caching'],
});
// All of these now fail in strict mode
// config.server.port = 8080; // TypeError
// config.server.ssl.enabled = false; // TypeError
// config.database.credentials.user = 'root'; // TypeError
// config.features.push('newFeature'); // TypeError
console.log(Object.isFrozen(config)); // true
console.log(Object.isFrozen(config.server)); // true
console.log(Object.isFrozen(config.server.ssl)); // true
console.log(Object.isFrozen(config.database.credentials)); // true
console.log(Object.isFrozen(config.features)); // true
Date, Map, Set, or RegExp because freezing them breaks their internal methods. Also, freezing large objects recursively has a performance cost. Use deep freeze strategically for critical immutable data, not as a blanket approach for all objects in your application.structuredClone -- Deep Copying
structuredClone() is a modern JavaScript method (available in all major browsers and Node.js 17+) that creates a true deep copy of a value. Unlike the spread operator or Object.assign, structuredClone recursively copies all nested objects and arrays, producing a completely independent clone with no shared references to the original. This makes it invaluable for creating immutable copies of complex data structures.
Example: structuredClone for Deep Copying
const original = {
user: {
name: 'Alice',
address: {
city: 'New York',
coordinates: { lat: 40.7128, lng: -74.0060 },
},
},
tags: ['developer', 'mentor'],
metadata: {
created: new Date('2024-01-15'),
scores: [95, 87, 92],
},
};
// Deep clone with structuredClone
const clone = structuredClone(original);
// Modify the clone -- original is completely unaffected
clone.user.name = 'Bob';
clone.user.address.city = 'San Francisco';
clone.user.address.coordinates.lat = 37.7749;
clone.tags.push('speaker');
clone.metadata.scores.push(100);
console.log(original.user.name); // "Alice" -- unchanged
console.log(original.user.address.city); // "New York" -- unchanged
console.log(original.user.address.coordinates.lat); // 40.7128 -- unchanged
console.log(original.tags); // ['developer', 'mentor'] -- unchanged
console.log(original.metadata.scores); // [95, 87, 92] -- unchanged
// structuredClone handles special types
const withSpecialTypes = {
date: new Date(),
regex: /hello/gi, // Note: RegExp is NOT supported by structuredClone
map: new Map([['key', 'value']]),
set: new Set([1, 2, 3]),
buffer: new ArrayBuffer(8),
};
const specialClone = structuredClone({
date: new Date(),
map: new Map([['key', 'value']]),
set: new Set([1, 2, 3]),
});
console.log(specialClone.date instanceof Date); // true
console.log(specialClone.map instanceof Map); // true
console.log(specialClone.set instanceof Set); // true
structuredClone cannot clone functions, DOM nodes, or objects with Symbol property keys. If your objects contain functions (like methods or callbacks), you will need a custom cloning approach. Also, structuredClone does not preserve the prototype chain -- cloned objects lose their class associations. For plain data objects (which is the most common use case in state management), structuredClone is the best built-in option for deep copying. For older environments that lack structuredClone, the classic workaround is JSON.parse(JSON.stringify(obj)), but this loses Date objects, undefined values, Map, Set, and any other non-JSON-safe types.Immutable Nested Updates
The most challenging aspect of immutability is updating deeply nested properties. You need to create new objects at every level of the nesting hierarchy, spreading in the unchanged properties while overriding only the one you need to change. This produces verbose but predictable code. The pattern is to spread each level from the outside in, replacing only the branch that leads to the property being changed.
Example: Immutable Nested Object Updates
const state = {
user: {
profile: {
name: 'Alice',
address: {
street: '123 Main St',
city: 'New York',
zip: '10001',
},
},
settings: {
theme: 'dark',
notifications: { email: true, sms: false, push: true },
},
},
posts: [
{ id: 1, title: 'First Post', likes: 10 },
{ id: 2, title: 'Second Post', likes: 25 },
],
};
// Update a deeply nested property: user.profile.address.city
const updatedCity = {
...state,
user: {
...state.user,
profile: {
...state.user.profile,
address: {
...state.user.profile.address,
city: 'San Francisco',
},
},
},
};
console.log(state.user.profile.address.city); // "New York" -- unchanged
console.log(updatedCity.user.profile.address.city); // "San Francisco"
// Unchanged branches keep their references (efficient!)
console.log(state.user.settings === updatedCity.user.settings); // true
// Update nested notification setting
const toggledSms = {
...state,
user: {
...state.user,
settings: {
...state.user.settings,
notifications: {
...state.user.settings.notifications,
sms: !state.user.settings.notifications.sms,
},
},
},
};
// Update a specific item in an array
const likedPost = {
...state,
posts: state.posts.map(post =>
post.id === 1
? { ...post, likes: post.likes + 1 }
: post
),
};
console.log(state.posts[0].likes); // 10
console.log(likedPost.posts[0].likes); // 11
console.log(state.posts[1] === likedPost.posts[1]); // true -- unchanged post kept
Example: Helper Function for Deep Immutable Updates
// A utility to perform immutable updates at a given path
function updatePath(obj, path, updater) {
if (path.length === 0) {
return typeof updater === 'function' ? updater(obj) : updater;
}
const [head, ...rest] = path;
if (Array.isArray(obj)) {
return obj.map((item, index) =>
index === head ? updatePath(item, rest, updater) : item
);
}
return {
...obj,
[head]: updatePath(obj[head], rest, updater),
};
}
const state = {
users: [
{ name: 'Alice', scores: [90, 85, 95] },
{ name: 'Bob', scores: [80, 75, 88] },
],
};
// Update Alice's second score
const updated = updatePath(state, ['users', 0, 'scores', 1], 92);
console.log(state.users[0].scores[1]); // 85 -- unchanged
console.log(updated.users[0].scores[1]); // 92
// Increment Bob's first score
const incremented = updatePath(state, ['users', 1, 'scores', 0], prev => prev + 5);
console.log(incremented.users[1].scores[0]); // 85
// Change Alice's name
const renamed = updatePath(state, ['users', 0, 'name'], 'Alice Smith');
console.log(renamed.users[0].name); // "Alice Smith"
console.log(state.users[0].name); // "Alice" -- original unchanged
What Are Pure Functions?
A pure function is a function that satisfies two strict requirements. First, given the same inputs, it always returns the same output -- this is called determinism. Second, it produces no side effects -- it does not modify anything outside its own scope, including its input arguments, global variables, files, databases, the DOM, or the console. A pure function is like a mathematical function: it maps inputs to outputs and nothing else. It does not depend on or alter any external state. The combination of immutability and pure functions is the foundation of functional programming, and together they make your code dramatically easier to understand, test, and maintain.
Example: Pure vs Impure Functions
// PURE: Same input always gives same output, no side effects
function add(a, b) {
return a + b;
}
function calculateArea(radius) {
return Math.PI * radius * radius;
}
function formatUser(user) {
return `${user.firstName} ${user.lastName}`;
}
function filterAdults(people) {
return people.filter(person => person.age >= 18);
}
// IMPURE: Depends on external state
let taxRate = 0.08;
function calculateTotal(price) {
return price * (1 + taxRate); // Depends on external variable
// If taxRate changes, same price gives different result
}
// IMPURE: Modifies external state
let callCount = 0;
function trackableAdd(a, b) {
callCount++; // Side effect: modifies external variable
return a + b;
}
// IMPURE: Modifies input argument
function addToCart(cart, item) {
cart.push(item); // Mutates the input array
return cart;
}
// PURE version of addToCart
function addToCartPure(cart, item) {
return [...cart, item]; // Returns new array, input unchanged
}
// IMPURE: Non-deterministic (different output for same input)
function getUserGreeting(user) {
const hour = new Date().getHours(); // Depends on current time
if (hour < 12) return `Good morning, ${user.name}`;
if (hour < 18) return `Good afternoon, ${user.name}`;
return `Good evening, ${user.name}`;
}
// PURE version: make the dependency explicit
function getUserGreetingPure(user, hour) {
if (hour < 12) return `Good morning, ${user.name}`;
if (hour < 18) return `Good afternoon, ${user.name}`;
return `Good evening, ${user.name}`;
}
Referential Transparency
A pure function exhibits a property called referential transparency. This means any call to the function can be replaced with its return value without changing the behavior of the program. If add(2, 3) always returns 5, then anywhere in your code where you see add(2, 3), you can mentally (or literally) replace it with 5 and the program behaves identically. This is an incredibly powerful property for reasoning about code because you can evaluate parts of your program in isolation. Referential transparency makes refactoring safer, enables compiler optimizations, and simplifies debugging by letting you reason about individual expressions without worrying about hidden state changes.
Example: Referential Transparency in Practice
// Pure functions are referentially transparent
function double(x) { return x * 2; }
function increment(x) { return x + 1; }
function square(x) { return x * x; }
// This expression:
const result1 = double(increment(square(3)));
// Can be traced step by step by replacing function calls with values:
// square(3) => 9
// increment(9) => 10
// double(10) => 20
const result2 = 20;
console.log(result1 === result2); // true -- referentially transparent
// You can reorder pure function calls freely
const a = double(5); // 10
const b = square(3); // 9
const c = increment(4); // 5
// The order of these three lines does not matter because
// none of them affect or depend on each other
// Impure functions break referential transparency
let counter = 0;
function impureIncrement() {
counter++;
return counter;
}
// impureIncrement() !== impureIncrement()
// First call returns 1, second returns 2
// You CANNOT replace the call with a fixed value
const x = impureIncrement(); // 1
const y = impureIncrement(); // 2 -- different result for same (no) input!
// This makes reasoning about the code much harder
// because order of execution now matters critically
Benefits of Pure Functions: Testing and Caching
Two of the most tangible benefits of pure functions are simplified testing and automatic caching through memoization. Because pure functions have no dependencies on external state and produce no side effects, testing them requires nothing more than providing inputs and asserting outputs. And because the same inputs always produce the same output, you can cache (memoize) the results to avoid redundant computation.
Example: Easy Testing of Pure Functions
// Pure function -- trivially easy to test
function calculateDiscount(price, discountPercent) {
if (price <= 0 || discountPercent < 0 || discountPercent > 100) {
return 0;
}
return Math.round(price * (discountPercent / 100) * 100) / 100;
}
// Tests require zero setup -- just input and output
console.assert(calculateDiscount(100, 10) === 10, '10% of 100');
console.assert(calculateDiscount(49.99, 25) === 12.5, '25% of 49.99');
console.assert(calculateDiscount(0, 50) === 0, 'Zero price');
console.assert(calculateDiscount(100, -5) === 0, 'Negative discount');
console.assert(calculateDiscount(100, 150) === 0, 'Over 100% discount');
// Pure sorting comparator
function sortByPrice(a, b) {
return a.price - b.price;
}
// Pure filter predicate
function isInStock(product) {
return product.stock > 0;
}
// Pure transformer
function toSummary(product) {
return {
name: product.name,
price: `$${product.price.toFixed(2)}`,
available: product.stock > 0,
};
}
// Each function can be tested independently
console.assert(sortByPrice({ price: 10 }, { price: 20 }) < 0, 'sort ascending');
console.assert(isInStock({ stock: 5 }) === true, 'in stock');
console.assert(isInStock({ stock: 0 }) === false, 'out of stock');
console.assert(toSummary({ name: 'Widget', price: 9.99, stock: 3 }).price === '$9.99', 'format price');
Example: Memoization -- Caching Pure Function Results
// Generic memoization wrapper for pure functions
function memoize(fn) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`Cache hit for args: ${key}`);
return cache.get(key);
}
console.log(`Computing for args: ${key}`);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
// Expensive pure function -- perfect candidate for memoization
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const memoFib = memoize(function fib(n) {
if (n <= 1) return n;
return memoFib(n - 1) + memoFib(n - 2);
});
console.time('first call');
console.log(memoFib(35)); // Computing... takes time on first call
console.timeEnd('first call');
console.time('second call');
console.log(memoFib(35)); // Cache hit -- instant!
console.timeEnd('second call');
// Memoize a data transformation
const processData = memoize(function(data, filters) {
console.log('Processing...');
return data
.filter(item => item.category === filters.category)
.map(item => ({ ...item, price: item.price * (1 - filters.discount) }))
.sort((a, b) => a.price - b.price);
});
const products = [
{ name: 'A', category: 'electronics', price: 100 },
{ name: 'B', category: 'books', price: 20 },
{ name: 'C', category: 'electronics', price: 50 },
];
const filters = { category: 'electronics', discount: 0.1 };
processData(products, filters); // "Processing..." -- computes
processData(products, filters); // "Cache hit" -- returns cached result
Identifying and Eliminating Side Effects
A side effect is any observable change that a function makes to the world outside its own scope. Side effects include modifying global or outer scope variables, mutating function arguments, writing to the console, making HTTP requests, reading from or writing to the DOM, reading from or writing to files or databases, generating random numbers, and getting the current date or time. The goal of functional programming is not to eliminate all side effects (your program must eventually interact with the outside world) but to isolate them, push them to the boundaries of your program, and keep the core logic pure.
Example: Identifying Common Side Effects
// Side effect: Modifying external state
let total = 0;
function addToTotal(amount) {
total += amount; // SIDE EFFECT: modifies outer variable
return total;
}
// PURE alternative: return new total
function addToTotalPure(currentTotal, amount) {
return currentTotal + amount; // No side effect
}
// Side effect: Modifying input arguments
function sortUsers(users) {
users.sort((a, b) => a.name.localeCompare(b.name)); // SIDE EFFECT: mutates input
return users;
}
// PURE alternative: return new sorted array
function sortUsersPure(users) {
return [...users].sort((a, b) => a.name.localeCompare(b.name));
}
// Side effect: DOM manipulation
function updateDisplay(message) {
document.getElementById('output').textContent = message; // SIDE EFFECT
}
// PURE alternative: return the data, let caller handle DOM
function formatMessage(data) {
return `${data.user}: ${data.message} (${data.timestamp})`;
}
// Side effect is isolated to the caller:
// document.getElementById('output').textContent = formatMessage(data);
// Side effect: HTTP requests
async function fetchAndProcess(url) {
const response = await fetch(url); // SIDE EFFECT: network request
const data = await response.json();
return data.map(item => item.name); // This part is pure
}
// Better: separate the side effect from the pure transformation
async function fetchData(url) {
const response = await fetch(url); // Side effect isolated here
return response.json();
}
function extractNames(data) { // Pure transformation
return data.map(item => item.name);
}
// Usage: side effect then pure transformation
// const data = await fetchData(url);
// const names = extractNames(data);
Example: Refactoring Impure Code to Pure Code
// BEFORE: Impure shopping cart with many side effects
let cart = [];
let orderCount = 0;
function addItem(name, price) {
cart.push({ name, price, id: ++orderCount }); // Mutates cart, orderCount
console.log(`Added ${name}`); // Console side effect
updateCartDisplay(); // DOM side effect
saveToLocalStorage(); // Storage side effect
}
function getTotal() {
let sum = 0;
for (const item of cart) {
sum += item.price;
}
return sum;
}
// AFTER: Pure core logic with side effects at the boundaries
// Pure functions -- no side effects, easily testable
function addItemToCart(cart, item) {
return [...cart, { ...item, id: cart.length + 1 }];
}
function removeItemFromCart(cart, itemId) {
return cart.filter(item => item.id !== itemId);
}
function calculateTotal(cart) {
return cart.reduce((sum, item) => sum + item.price, 0);
}
function applyDiscount(cart, discountPercent) {
return cart.map(item => ({
...item,
price: Math.round(item.price * (1 - discountPercent / 100) * 100) / 100,
}));
}
function getCartSummary(cart) {
return {
items: cart.length,
total: calculateTotal(cart),
itemNames: cart.map(item => item.name),
};
}
// Side effects isolated in one place -- the "shell"
function handleAddItem(name, price) {
const currentCart = getCartFromState(); // Read state
const newCart = addItemToCart(currentCart, { name, price }); // Pure
const summary = getCartSummary(newCart); // Pure
saveCartToState(newCart); // Write state
renderCart(summary); // Update DOM
console.log(`Added ${name} -- Total: $${summary.total}`); // Log
}
// Testing the pure functions requires ZERO setup
console.assert(
calculateTotal([{ price: 10 }, { price: 20 }]) === 30,
'Total calculation'
);
const testCart = [{ id: 1, name: 'A', price: 50 }];
const withB = addItemToCart(testCart, { name: 'B', price: 30 });
console.assert(withB.length === 2, 'Item added');
console.assert(testCart.length === 1, 'Original unchanged');
Practice Exercise
Build an immutable state management system for a task manager application. Start by defining an initial state object with the following structure: a tasks array (each task has id, title, completed, priority, and tags properties), a filters object (with status, priority, and searchTerm properties), and a ui object (with selectedTaskId and sortBy properties). All state updates must be immutable -- never modify the existing state. Implement the following pure functions: addTask(state, taskData) that returns a new state with the task added, toggleTask(state, taskId) that returns a new state with the task's completed status flipped, updateTaskTags(state, taskId, newTags) that returns a new state with the task's tags replaced, deleteTask(state, taskId) that returns a new state without the specified task, setFilter(state, filterName, value) that returns a new state with the filter updated, getVisibleTasks(state) that returns a filtered and sorted array of tasks based on the current filters and sort settings (this is a pure derived computation). Verify immutability by checking that the original state is unchanged after each operation. Use Object.freeze or deepFreeze on your initial state to guarantee no accidental mutations. Write at least 10 test assertions that verify both the correctness of each function's output and the immutability of the input state. Bonus: implement an undo function by maintaining a history array of previous states -- immutability makes this trivial since each state is a complete snapshot.