Writing Pure Functions in JS: A Complete Tutorial
Learn how to write pure functions in JavaScript with practical examples. Covers avoiding side effects, immutable data patterns, composing pure functions, refactoring impure code, and building testable applications.
Pure functions are the building blocks of reliable JavaScript code. They accept inputs, return outputs, and do nothing else. No hidden state changes, no surprise behavior, no testing headaches. This tutorial gives you a step-by-step approach to writing pure functions, converting impure code into pure alternatives, and composing pure functions into larger operations.
If you are new to the concept, read Pure vs Impure Functions in JavaScript Explained first for the foundational definitions and comparison.
The Three Rules for Writing Pure Functions
Every pure function follows three rules:
- Use only the parameters you receive - do not read external variables
- Return a new value - do not modify the original data
- Produce no side effects - no console, DOM, network, or external state changes
// Rule 1: Uses only its parameters
// Rule 2: Returns a new value
// Rule 3: No side effects
function greet(name) {
return `Hello, ${name}!`;
}Step 1: Pass Everything as Arguments
The most common source of impurity is reading external variables. Fix this by passing all dependencies as arguments:
// IMPURE: reads external state
const config = { currency: "USD", taxRate: 0.08 };
function formatPrice(amount) {
const tax = amount * config.taxRate;
return `${config.currency} ${(amount + tax).toFixed(2)}`;
}
// PURE: all dependencies are arguments
function formatPrice(amount, currency, taxRate) {
const tax = amount * taxRate;
return `${currency} ${(amount + tax).toFixed(2)}`;
}
console.log(formatPrice(100, "USD", 0.08)); // "USD 108.00"
console.log(formatPrice(100, "EUR", 0.20)); // "EUR 120.00"Configuration as Arguments
When a function depends on configuration, pass the config object (or individual values) as a parameter. You can set default parameter values to avoid repetition while keeping the function pure.
Using Default Parameters
function formatPrice(amount, currency = "USD", taxRate = 0.08) {
const tax = amount * taxRate;
return `${currency} ${(amount + tax).toFixed(2)}`;
}
// Uses defaults: still pure because defaults are constant
console.log(formatPrice(100)); // "USD 108.00"
console.log(formatPrice(100, "GBP", 0.2)); // "GBP 120.00"Step 2: Never Mutate Arguments
JavaScript objects and arrays are passed by reference. If you modify them inside a function, you are changing the caller's data:
// IMPURE: mutates the original array
function addUser(users, newUser) {
users.push(newUser); // modifies the original!
return users;
}
const team = [{ name: "Alice" }];
const updated = addUser(team, { name: "Bob" });
console.log(team.length); // 2 (original was mutated!)
console.log(team === updated); // true (same reference)Fix: Return New Data Structures
// PURE: returns a new array
function addUser(users, newUser) {
return [...users, newUser];
}
const team = [{ name: "Alice" }];
const updated = addUser(team, { name: "Bob" });
console.log(team.length); // 1 (unchanged)
console.log(updated.length); // 2
console.log(team === updated); // false (different reference)Common Immutable Patterns
// Add to array
const addItem = (arr, item) => [...arr, item];
// Remove from array by index
const removeAt = (arr, index) => arr.filter((_, i) => i !== index);
// Update item in array
const updateAt = (arr, index, newValue) =>
arr.map((item, i) => (i === index ? newValue : item));
// Add property to object
const addProp = (obj, key, value) => ({ ...obj, [key]: value });
// Remove property from object
const removeProp = (obj, key) => {
const { [key]: _, ...rest } = obj;
return rest;
};
// Update nested object
const updateNested = (obj, path, value) => ({
...obj,
settings: { ...obj.settings, [path]: value },
});Step 3: Replace Side Effects with Return Values
Instead of performing actions inside a function, return the data needed to perform those actions:
// IMPURE: performs the side effect
function processOrder(order) {
console.log(`Processing order ${order.id}`);
document.title = `Order ${order.id}`;
return order;
}
// PURE: returns instructions for the caller
function processOrder(order) {
return {
order,
logMessage: `Processing order ${order.id}`,
pageTitle: `Order ${order.id}`,
};
}
// Caller handles the side effects
const result = processOrder({ id: 123 });
console.log(result.logMessage);
document.title = result.pageTitle;Composing Pure Functions
Small pure functions combine naturally into larger operations. Each step transforms data and passes it to the next:
// Individual pure functions
function parseAmount(input) {
return parseFloat(input.replace(/[^0-9.]/g, ""));
}
function applyDiscount(amount, discountPercent) {
return amount - amount * (discountPercent / 100);
}
function applyTax(amount, taxRate) {
return amount + amount * taxRate;
}
function roundToTwoDecimals(amount) {
return Math.round(amount * 100) / 100;
}
function formatCurrency(amount, symbol = "$") {
return `${symbol}${amount.toFixed(2)}`;
}
// Compose them into a pipeline
function calculateFinalPrice(rawInput, discount, taxRate) {
const parsed = parseAmount(rawInput);
const discounted = applyDiscount(parsed, discount);
const taxed = applyTax(discounted, taxRate);
const rounded = roundToTwoDecimals(taxed);
return formatCurrency(rounded);
}
console.log(calculateFinalPrice("$99.99", 10, 0.08)); // "$97.19"Generic Pipe Utility
function pipe(...fns) {
return (initialValue) => fns.reduce((value, fn) => fn(value), initialValue);
}
const processName = pipe(
(name) => name.trim(),
(name) => name.toLowerCase(),
(name) => name.replace(/\s+/g, "-"),
(name) => name.slice(0, 50)
);
console.log(processName(" Hello World ")); // "hello-world"Pipe Reads Left to Right
The pipe function applies functions from left to right, which matches how most people read code. This is the opposite of mathematical composition (which reads right to left).
Refactoring Real-World Examples
Example 1: User Validation
// BEFORE: impure (reads external config, mutates errors array)
let validationConfig = {
minNameLength: 2,
maxNameLength: 50,
emailPattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
};
let errors = [];
function validateUser(user) {
errors = []; // resets external state
if (user.name.length < validationConfig.minNameLength) {
errors.push("Name too short");
}
if (!validationConfig.emailPattern.test(user.email)) {
errors.push("Invalid email");
}
return errors.length === 0;
}
// AFTER: pure (everything is a parameter, returns new data)
function validateUser(user, config) {
const errors = [];
if (user.name.length < config.minNameLength) {
errors.push("Name too short");
}
if (!config.emailPattern.test(user.email)) {
errors.push("Invalid email");
}
return { isValid: errors.length === 0, errors };
}
const result = validateUser(
{ name: "A", email: "bad" },
{ minNameLength: 2, emailPattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }
);
console.log(result); // { isValid: false, errors: ["Name too short", "Invalid email"] }Example 2: Cart Operations
// Pure cart operations
function addToCart(cart, product, quantity = 1) {
const existing = cart.find((item) => item.id === product.id);
if (existing) {
return cart.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + quantity }
: item
);
}
return [...cart, { ...product, quantity }];
}
function removeFromCart(cart, productId) {
return cart.filter((item) => item.id !== productId);
}
function getCartTotal(cart) {
return cart.reduce((total, item) => total + item.price * item.quantity, 0);
}
function applyCartDiscount(cart, discountPercent) {
return cart.map((item) => ({
...item,
price: item.price * (1 - discountPercent / 100),
}));
}
// Usage: each function returns a new cart
let cart = [];
cart = addToCart(cart, { id: 1, name: "Book", price: 25 });
cart = addToCart(cart, { id: 2, name: "Pen", price: 5 }, 3);
cart = addToCart(cart, { id: 1, name: "Book", price: 25 }); // quantity becomes 2
console.log(getCartTotal(cart)); // 65Example 3: Data Transformation Pipeline
const rawUsers = [
{ name: "alice smith", age: 25, active: true },
{ name: "bob jones", age: 17, active: false },
{ name: "charlie brown", age: 30, active: true },
];
// Each step is a pure function
const capitalize = (str) =>
str.replace(/\b\w/g, (c) => c.toUpperCase());
const formatUser = (user) => ({
...user,
name: capitalize(user.name),
displayAge: `${user.age} years`,
});
const isAdult = (user) => user.age >= 18;
const isActive = (user) => user.active;
// Compose with array methods (all pure)
const result = rawUsers
.filter(isAdult)
.filter(isActive)
.map(formatUser);
console.log(result);
// [{ name: "Alice Smith", age: 25, active: true, displayAge: "25 years" },
// { name: "Charlie Brown", age: 30, active: true, displayAge: "30 years" }]Handling Edge Cases Purely
Null/Undefined Safety
function getUserName(user) {
if (!user) return "Anonymous";
if (!user.name) return "Unnamed User";
return user.name.trim();
}
console.log(getUserName(null)); // "Anonymous"
console.log(getUserName({})); // "Unnamed User"
console.log(getUserName({ name: " Alice "})); // "Alice"Handling Optional Properties
function getFullAddress(address) {
const parts = [
address.street,
address.unit ? `Unit ${address.unit}` : null,
address.city,
address.state,
address.zip,
].filter(Boolean);
return parts.join(", ");
}
console.log(getFullAddress({ street: "123 Main", city: "NYC", state: "NY", zip: "10001" }));
// "123 Main, NYC, NY, 10001"Testing Pure Functions
Pure functions require minimal test setup. Each test is a simple input-output assertion:
// Functions to test
function slugify(text) {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
// Tests: no setup, no mocking, no cleanup
const tests = [
// slugify tests
[slugify("Hello World"), "hello-world"],
[slugify(" spaces "), "spaces"],
[slugify("Special!@#Chars"), "special-chars"],
[slugify("Already-Clean"), "already-clean"],
// clamp tests
[clamp(5, 0, 10), 5],
[clamp(-5, 0, 10), 0],
[clamp(15, 0, 10), 10],
[clamp(0, 0, 10), 0],
];
tests.forEach(([actual, expected], i) => {
console.assert(actual === expected, `Test ${i + 1}: got ${actual}, expected ${expected}`);
});Common Mistakes to Avoid
1. Accidental Closure Over Mutable State
// Looks pure, but closes over mutable variable
let multiplier = 2;
const double = (x) => x * multiplier; // reads external mutable state
// Fix: make it truly pure
const multiply = (x, factor) => x * factor;2. Forgetting That Objects Are References
// This does NOT create a deep copy
function updateSettings(settings, key, value) {
return { ...settings, [key]: value }; // shallow copy only!
}
const original = { theme: { color: "blue", size: 14 } };
const updated = updateSettings(original, "theme", original.theme);
updated.theme.color = "red";
console.log(original.theme.color); // "red" (mutated because shallow copy!)
// Fix: deep copy nested objects
function updateSettings(settings, key, value) {
return { ...settings, [key]: { ...value } };
}3. Using Array Methods That Mutate
// sort() mutates the original!
function getSortedNames(users) {
return users.sort((a, b) => a.name.localeCompare(b.name)); // impure!
}
// Fix: copy first
function getSortedNames(users) {
return [...users].sort((a, b) => a.name.localeCompare(b.name));
}Practical Guidelines
| Guideline | Why |
|---|---|
| Keep functions small (under 15 lines) | Easier to verify purity |
| Name functions after what they return | getTotal, formatDate, isValid |
| Use arrow functions for one-liners | const double = (x) => x * 2; |
| Avoid var inside pure functions | Use const for all local bindings |
| Test with edge cases | Empty arrays, null, undefined, zero, negative numbers |
| Extract I/O to callers | Keep the core logic pure, let callers handle side effects |
Rune AI
Key Insights
- Pass all data as arguments - never read external mutable state
- Return new values - use spread operator and non-mutating array methods
- Replace side effects with return values - let callers handle I/O
- Compose small functions - pipe/chain pure functions into larger operations
- Test with simple assertions - pure functions need no mocks or setup
- Keep impurity at the edges - event handlers, API calls, and DOM updates are the boundary
Frequently Asked Questions
How do I handle async operations purely?
Should I freeze objects to enforce immutability?
What about closures - are they pure?
Conclusion
Writing pure functions means passing all dependencies as arguments, never mutating inputs, and returning new data instead of modifying external state. Use spread operators and array methods like map(), filter(), and reduce() to transform data immutably. Compose small pure functions into pipelines for complex operations. Push side effects to the boundaries of your application - callback functions, event handlers, and API routes handle the impure work while your core logic stays clean and testable.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.