Pure Functions and Side Effect Management
Pure functions are the foundation of functional programming. A pure function is a function where the return value is determined only by its input values, without observable side effects. This predictability makes code easier to test, reason about, and parallelize.
Core Characteristics of Pure Functions
A pure function must satisfy two key properties:
- Deterministic: Given the same inputs, it always returns the same output
- No Side Effects: It doesn't modify external state or interact with the outside world
Example: Pure vs Impure Functions (JavaScript)
// IMPURE: Depends on external state
let discount = 0.1;
function calculatePrice(price) {
return price - (price * discount);
}
// PURE: All inputs are parameters
function calculatePriceWithDiscount(price, discount) {
return price - (price * discount);
}
// IMPURE: Modifies external state
let total = 0;
function addToTotal(amount) {
total += amount;
return total;
}
// PURE: Returns new value without mutation
function add(a, b) {
return a + b;
}
// Usage of pure function
const currentTotal = 100;
const newTotal = add(currentTotal, 50); // 150
// currentTotal is still 100
Example: Pure Functions in Python
from datetime import datetime
from typing import List, Dict
# IMPURE: Uses current time (non-deterministic)
def get_greeting():
hour = datetime.now().hour
if hour < 12:
return "Good morning"
return "Good afternoon"
# PURE: Time is passed as parameter
def get_greeting_at_time(hour: int) -> str:
if hour < 12:
return "Good morning"
return "Good afternoon"
# IMPURE: Modifies input list
def add_item_impure(items: List[str], item: str) -> List[str]:
items.append(item)
return items
# PURE: Returns new list
def add_item_pure(items: List[str], item: str) -> List[str]:
return [*items, item]
# IMPURE: Reads from file system
def load_config():
with open('config.json', 'r') as f:
return json.load(f)
# PURE: Config is passed as parameter
def process_config(config: Dict) -> Dict:
return {
**config,
'processed': True,
'timestamp': config.get('timestamp', 0)
}
Isolating Side Effects
When side effects are necessary, isolate them at the boundaries:
JavaScript: Functional Core, Imperative Shell
// PURE CORE: Business logic
function calculateOrderTotal(items, taxRate, shippingCost) {
const subtotal = items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
const tax = subtotal * taxRate;
return subtotal + tax + shippingCost;
}
function validateOrder(order, inventory) {
const errors = [];
for (const item of order.items) {
const stock = inventory[item.id];
if (!stock || stock < item.quantity) {
errors.push(`Insufficient stock for ${item.name}`);
}
}
if (order.items.length === 0) {
errors.push('Order must contain at least one item');
}
return {
valid: errors.length === 0,
errors
};
}
function createInvoice(order, total) {
return {
orderId: order.id,
customerId: order.customerId,
items: order.items,
total,
createdAt: order.timestamp
};
}
// IMPERATIVE SHELL: Side effects isolated here
async function processOrder(orderId) {
// Side effect: Database read
const order = await db.orders.findById(orderId);
const inventory = await db.inventory.getAll();
// Pure function call
const validation = validateOrder(order, inventory);
if (!validation.valid) {
// Side effect: Logging
console.error('Order validation failed:', validation.errors);
return { success: false, errors: validation.errors };
}
// Pure function call
const total = calculateOrderTotal(
order.items,
order.taxRate,
order.shippingCost
);
// Pure function call
const invoice = createInvoice(order, total);
// Side effects: Database writes
await db.invoices.create(invoice);
await db.orders.update(orderId, { status: 'processed' });
// Side effect: Email
await emailService.send(order.customerEmail, invoice);
return { success: true, invoice };
}
Python: Dependency Injection for Purity
from dataclasses import dataclass
from typing import List, Dict, Callable
from datetime import datetime
@dataclass
class Order:
id: str
items: List[Dict]
customer_id: str
tax_rate: float
shipping_cost: float
# PURE: All dependencies are parameters
def calculate_order_total(
items: List[Dict],
tax_rate: float,
shipping_cost: float
) -> float:
subtotal = sum(item['price'] * item['quantity'] for item in items)
tax = subtotal * tax_rate
return subtotal + tax + shipping_cost
def validate_order(order: Order, inventory: Dict[str, int]) -> Dict:
errors = []
for item in order.items:
stock = inventory.get(item['id'], 0)
if stock < item['quantity']:
errors.append(f"Insufficient stock for {item['name']}")
if not order.items:
errors.append('Order must contain at least one item')
return {
'valid': len(errors) == 0,
'errors': errors
}
def create_invoice(order: Order, total: float, timestamp: datetime) -> Dict:
return {
'order_id': order.id,
'customer_id': order.customer_id,
'items': order.items,
'total': total,
'created_at': timestamp.isoformat()
}
# IMPURE: But dependencies injected for testability
class OrderProcessor:
def __init__(self, db, email_service, logger, clock: Callable[[], datetime]):
self.db = db
self.email_service = email_service
self.logger = logger
self.clock = clock # Inject time dependency
async def process_order(self, order_id: str) -> Dict:
# Side effect: Database read
order = await self.db.orders.find_by_id(order_id)
inventory = await self.db.inventory.get_all()
# Pure function call
validation = validate_order(order, inventory)
if not validation['valid']:
# Side effect: Logging (injected)
self.logger.error(f"Order validation failed: {validation['errors']}")
return {'success': False, 'errors': validation['errors']}
# Pure function calls
total = calculate_order_total(
order.items,
order.tax_rate,
order.shipping_cost
)
# Time is injected, making this testable
invoice = create_invoice(order, total, self.clock())
# Side effects: Database writes
await self.db.invoices.create(invoice)
await self.db.orders.update(order_id, {'status': 'processed'})
# Side effect: Email
await self.email_service.send(order.customer_email, invoice)
return {'success': True, 'invoice': invoice}
Referential Transparency
A pure function exhibits referential transparency: you can replace a function call with its return value without changing program behavior.
// Pure function
function add(a, b) {
return a + b;
}
// These are equivalent due to referential transparency
const result1 = add(2, 3) * add(4, 5);
const result2 = 5 * 9; // Can replace function calls with values
const result3 = 45;
// Impure function (random)
function getRandomNumber() {
return Math.random();
}
// These are NOT equivalent
const value1 = getRandomNumber() + getRandomNumber();
const value2 = 0.5 + 0.5; // Wrong! Can't replace with specific value
Testing Pure Functions
Pure functions are trivial to test:
import pytest
from decimal import Decimal
# Pure function
def calculate_compound_interest(
principal: Decimal,
rate: Decimal,
years: int,
compounds_per_year: int
) -> Decimal:
return principal * (1 + rate / compounds_per_year) ** (compounds_per_year * years)
# Tests are simple - no mocking needed
def test_compound_interest_basic():
result = calculate_compound_interest(
principal=Decimal('1000'),
rate=Decimal('0.05'),
years=1,
compounds_per_year=12
)
assert result == pytest.approx(Decimal('1051.16'), rel=1e-2)
def test_compound_interest_zero_rate():
result = calculate_compound_interest(
principal=Decimal('1000'),
rate=Decimal('0'),
years=5,
compounds_per_year=4
)
assert result == Decimal('1000')
def test_compound_interest_multiple_years():
result = calculate_compound_interest(
principal=Decimal('5000'),
rate=Decimal('0.06'),
years=10,
compounds_per_year=4
)
assert result == pytest.approx(Decimal('9070.09'), rel=1e-2)
# No need for:
# - Database setup/teardown
# - Mocking external services
# - Clearing global state
# - Time manipulation
Language-Specific Examples and Advanced Patterns
The guidance above uses JavaScript for the primary examples. For the same patterns in other languages and deeper material, see:
- Pure Functions in Elixir and Haskell
When to Use This Skill
- Writing business logic that requires testing
- Implementing data transformations
- Building calculation engines
- Creating reusable utility functions
- Developing concurrent or parallel systems
- Refactoring legacy code for testability
- Implementing domain models
- Building composable abstractions
- Creating predictable APIs
- Optimizing for memoization or caching
Best Practices
- Make all inputs explicit - Pass every dependency as a parameter
- Avoid hidden dependencies - Don't access global variables, environment, or system state
- Return new values - Never mutate input parameters
- Keep functions focused - Single responsibility makes purity easier
- Inject time dependencies - Pass timestamps rather than calling Date.now() or datetime.now()
- Use immutable data structures - Choose languages/libraries that support immutability
- Separate pure from impure code - Keep business logic pure, isolate I/O at boundaries
- Document side effects explicitly - Use type systems (like Haskell's IO) or naming conventions
- Prefer expressions over statements - Return values instead of modifying state
- Make functions total - Handle all possible inputs, avoid throwing exceptions when possible
- Use pure data transformations - Map, filter, reduce instead of loops with mutations
- Avoid object-oriented patterns that encourage mutation - Prefer functional composition
- Test pure functions extensively - They're easy to test, take advantage of it
- Use pure functions for memoization - Cache results since output depends only on input
- Document assumptions - Make preconditions clear in types or documentation
Common Pitfalls
- Hidden I/O dependencies - Reading files, environment variables, or system time
- Mutating input parameters - Modifying arrays, objects, or other references
- Depending on closure variables - Using variables from outer scopes that can change
- Non-deterministic operations - Random numbers, timestamps, UUIDs without injection
- Logging inside pure functions - Even console.log is a side effect
- Throwing exceptions - Exceptions are side effects; use Result/Option types instead
- Accessing this or self - Often leads to hidden state dependencies
- Using impure standard library functions - Math.random(), Date.now(), etc.
- Relying on execution order - Pure functions should be order-independent
- Database or network calls - These are side effects that must be isolated
- Modifying global state - Including class variables, singletons, or registries
- DOM manipulation - Any interaction with external systems
- Side effects in getters - Properties should be pure computations
- Premature optimization - Don't sacrifice purity for minor performance gains
- Ignoring language constraints - Some languages make purity harder; use appropriate patterns
Resources
- "Functional Programming in JavaScript" by Luis Atencio
- "Grokking Simplicity" by Eric Normand
- "Functional Programming in Scala" by Paul Chiusano and Rúnar Bjarnason
- Haskell Wiki: Referential Transparency
- "Professor Frisby's Mostly Adequate Guide to Functional Programming"
- "Domain Modeling Made Functional" by Scott Wlaschin
- Martin Fowler: "Functional Core, Imperative Shell"
- "Clojure for the Brave and True" by Daniel Higginbotham