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:
- Same inputs → same outputs: Given the same arguments, it always returns the same result
- No side effects: It doesn't modify anything outside itself
Pure Functions
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
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:
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.
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):
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
// 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:
// Actually pure
function addItem(list, item) {
return [...list, item]; // Returns new array
}
Reading Global State
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:
function calculatePrice(basePrice, discount) {
return basePrice * (1 - discount);
}
Writing Global State
let lastCalculation = null;
function calculate(x) {
const result = x * 2;
lastCalculation = result; // Writes to global!
return result;
}
I/O Operations
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
// 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:
// 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:
// 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:
- Input layer (impure): Read from external sources
- Core logic (pure): Transform data
- Output layer (impure): Write to external sources
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.