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.

JavaScriptbeginner
10 min read

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:

  1. Use only the parameters you receive - do not read external variables
  2. Return a new value - do not modify the original data
  3. Produce no side effects - no console, DOM, network, or external state changes
javascriptjavascript
// 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:

javascriptjavascript
// 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

javascriptjavascript
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:

javascriptjavascript
// 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

javascriptjavascript
// 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

javascriptjavascript
// 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:

javascriptjavascript
// 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:

javascriptjavascript
// 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

javascriptjavascript
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

javascriptjavascript
// 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

javascriptjavascript
// 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)); // 65

Example 3: Data Transformation Pipeline

javascriptjavascript
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

javascriptjavascript
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

javascriptjavascript
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:

javascriptjavascript
// 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

javascriptjavascript
// 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

javascriptjavascript
// 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

javascriptjavascript
// 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

GuidelineWhy
Keep functions small (under 15 lines)Easier to verify purity
Name functions after what they returngetTotal, formatDate, isValid
Use arrow functions for one-linersconst double = (x) => x * 2;
Avoid var inside pure functionsUse const for all local bindings
Test with edge casesEmpty arrays, null, undefined, zero, negative numbers
Extract I/O to callersKeep the core logic pure, let callers handle side effects
Rune AI

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
RunePowered by Rune AI

Frequently Asked Questions

How do I handle async operations purely?

You cannot make a `fetch()` call pure because network requests are inherently impure. The pattern is to write pure functions that prepare the request data and process the response, then use a thin impure wrapper for the actual I/O.

Should I freeze objects to enforce immutability?

`Object.freeze()` prevents mutations at runtime but adds overhead. It is useful during development and testing to catch accidental mutations. In production, most teams rely on discipline and code review rather than freeze.

What about closures - are they pure?

closure that closes over a constant value is pure. A closure that closes over a mutable variable is impure. The key is whether the closed-over value can change between calls. ```javascript // Pure closure: multiplier never changes function createMultiplier(multiplier) { return (x) => x * multiplier; // multiplier is constant after creation } const triple = createMultiplier(3); console.log(triple(5)); // 15 (always) ```

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.