Lesson 14 of 54

Functions That Do One Thing

Side Effects and Pure Functions

A pure function is like a calculator: put in the same numbers, get out the same result. Every time. No surprises.

An impure function is like a vending machine with a mood: sometimes it gives you a snack, sometimes it keeps your money, sometimes it calls your manager.

Guess which one is easier to work with.

What Makes a Function Pure?

A function is pure if:

  1. Same inputs → same outputs: Given the same arguments, it always returns the same result
  2. No side effects: It doesn't modify anything outside itself

Pure Functions

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

function formatPrice(cents) {
  return `$${(cents / 100).toFixed(2)}`;
}

function sortByName(users) {
  return [...users].sort((a, b) => a.name.localeCompare(b.name));
}

function calculateTax(price, rate) {
  return price * rate;
}

Each of these:

  • Returns a predictable result
  • Doesn't modify its inputs
  • Doesn't touch anything outside itself

Impure Functions

Javascript
function getCurrentTime() {
  return Date.now();  // Different result each call
}

function getRandomNumber() {
  return Math.random();  // Different result each call
}

function saveUser(user) {
  db.users.insert(user);  // Modifies database
}

let counter = 0;
function incrementCounter() {
  counter++;  // Modifies external state
}

Why Pure Functions Matter

Testability

Pure functions are trivial to test:

Javascript
test('add returns sum of arguments', () => {
  expect(add(2, 3)).toBe(5);
  expect(add(-1, 1)).toBe(0);
  expect(add(0, 0)).toBe(0);
});

No mocks. No setup. No teardown. Just input → output.

Compare to testing a function that hits a database—you need fixtures, connections, cleanup...

Predictability

When you call a pure function, you know exactly what will happen: nothing, except you get a return value.

Javascript
const total = calculateTotal(items);  // I know this doesn't modify items

Debuggability

Pure functions are easy to debug:

  • Log the inputs and output, you've captured everything
  • No hidden state to track
  • Reproducible with the same inputs

Parallelization

Pure functions can run in parallel without locks or synchronization. They don't share mutable state.

Caching

Pure functions can be memoized (cached):

Javascript
const memoizedCalculate = memoize(expensiveCalculation);

// First call: computes
memoizedCalculate(100);

// Second call: returns cached result
memoizedCalculate(100);

This only works if the function is pure. If it has side effects, caching the result would skip those effects.

Hidden Side Effects

The most dangerous side effects are hidden ones. These are functions that look pure but aren't.

Modifying Input Arguments

Javascript
// LOOKS pure but ISN'T
function addItem(list, item) {
  list.push(item);  // Mutates the input!
  return list;
}

const myList = [1, 2, 3];
const newList = addItem(myList, 4);
// myList is now [1, 2, 3, 4] - surprise!

The fix:

Javascript
// Actually pure
function addItem(list, item) {
  return [...list, item];  // Returns new array
}

Reading Global State

Javascript
let discount = 0.1;

function calculatePrice(basePrice) {
  return basePrice * (1 - discount);  // Reads global!
}

// These calls return different results
discount = 0.1;
calculatePrice(100);  // 90

discount = 0.2;
calculatePrice(100);  // 80

The fix:

Javascript
function calculatePrice(basePrice, discount) {
  return basePrice * (1 - discount);
}

Writing Global State

Javascript
let lastCalculation = null;

function calculate(x) {
  const result = x * 2;
  lastCalculation = result;  // Writes to global!
  return result;
}

I/O Operations

Javascript
function processData(data) {
  const result = transform(data);
  console.log('Processed:', result);  // Side effect!
  return result;
}

Even console.log is a side effect. It outputs to the console.

Managing Side Effects

You can't eliminate side effects entirely. Programs that don't affect the outside world are useless.

The goal is to isolate side effects and make them explicit.

Push Side Effects to the Edges

Structure your code so:

  • Core logic is pure functions
  • I/O and state changes happen at the boundaries
Javascript
// Impure: I/O
async function loadUserData(userId) {
  return await db.users.findById(userId);
}

// Pure: transformation
function enrichUserData(user, permissions) {
  return {
    ...user,
    canEdit: permissions.includes('edit'),
    canDelete: permissions.includes('delete'),
    displayName: `${user.firstName} ${user.lastName}`,
  };
}

// Impure: I/O
async function saveUserData(user) {
  await db.users.update(user);
}

// Orchestrator: manages the impure parts
async function updateUserPermissions(userId, permissions) {
  const user = await loadUserData(userId);           // Impure
  const enrichedUser = enrichUserData(user, permissions); // Pure
  await saveUserData(enrichedUser);                  // Impure
}

The pure function enrichUserData is easy to test and reason about. The impure parts are thin wrappers.

Name Side Effects Honestly

If a function has side effects, the name should say so:

Javascript
// Bad: hidden side effect
function getUser(id) {
  analytics.track('user_fetched', { id });  // Hidden!
  return users.get(id);
}

// Good: explicit
function getUserAndTrackAccess(id) {
  analytics.track('user_fetched', { id });
  return users.get(id);
}

// Better: separate
function getUser(id) {
  return users.get(id);
}

function trackUserAccess(id) {
  analytics.track('user_fetched', { id });
}

Use Type Signatures

In typed languages, you can signal side effects in the type:

Typescript
// Pure: returns a value
function calculate(x: number): number { }

// Impure: returns Promise (async I/O)
async function fetchUser(id: string): Promise<User> { }

// Impure: returns void (mutation)
function saveUser(user: User): void { }

The Pure Core Pattern

A powerful pattern for complex systems:

  1. Input layer (impure): Read from external sources
  2. Core logic (pure): Transform data
  3. Output layer (impure): Write to external sources
Text
Input (impure) → Transform (pure) → Output (impure)

The pure core is easy to test, understand, and maintain. The impure edges are thin and isolated.


Key insight: Pure functions—same input, same output, no side effects—are easier to test, debug, and reason about. You can't avoid side effects entirely, but you can isolate them. Push impure code to the edges and keep your core logic pure.