It's the Call, Not the Function
Why a function with a real return value can still be a procedure in disguise, and why judging purity means watching the call, not just reading the definition.
I thought I had the rule down after the last post: a function needs a name that promises a real relationship between input and output. Then I looked at a function like this one and realized that rule still lets bad code through.
let taxRate = 0.08;
function totalWithTax(price) {
return price + price * taxRate;
}totalWithTax takes a price, returns a number, and the name genuinely describes the relationship. By every rule so far, this passes.
It still isn't a function in the full sense, and the reason is easy to miss.
Direct Inputs, Direct Outputs
totalWithTax doesn't just depend on price. It also depends on taxRate, a variable sitting outside the function entirely. Change taxRate anywhere else in the program, and this function's output changes too, without a single line of totalWithTax itself being edited.
That's the missing piece. A real function's inputs have to arrive only through its parameters, and its output has to leave only through return. Anything else, reading a variable it wasn't handed, writing to one it doesn't own, is a side effect.
Fix it by handing taxRate in like any other input:
function totalWithTax(price, taxRate) {
return price + price * taxRate;
}Same body, same name, same math. The only thing that changed is that nothing sneaks in from outside anymore. Now the function's entire behavior is visible in its signature.
Why "Semantic Relationship" Wasn't Enough
The earlier rule (a function's output has to depend on its input) is still true. It just isn't the whole story.
totalWithTax(price) reading a global taxRate still has a semantic relationship between price and its output. The relationship is just incomplete, because part of what decides the output never shows up as an input at all. Direct and traceable is the bar. A relationship you can't fully see from the function's signature doesn't clear it.
Why This Is About the Call, Not the Definition
Here's the part that reordered how I think about this entirely: whether a specific line of code behaves like a real function isn't something you can settle by reading the function's definition alone.
Say totalWithTax is written correctly, both price and taxRate as parameters. Now look at how it gets called:
const cart = { price: 40, taxRate: currentRegion.taxRate };
const total = totalWithTax(cart.price, cart.taxRate);The function itself is honest. But if currentRegion is a shared object that some other part of the app can mutate at any moment, then this particular call is only as trustworthy as whatever currentRegion happened to hold at that instant.
The function's definition can be pure. Whether a specific call stays pure depends on what actually got handed to it.
This is why looking only at how a function is written is never quite enough. You have to watch what shows up at the call site, not just trust that a clean-looking function body guarantees a clean result every time it runs.
What Actually Counts as a Side Effect
Once you start looking for indirect input and output, it shows up in more places than a stray global variable. An indirect input even has its own name, a side cause, though most people just lump both directions under "side effects."
(module var, shared object)
(console, file, DOM)
network calls
randomness
All four break the same rule: the output stops being fully explained by the input, the same rule that already ruled out a bare return 40 for having no relationship at all.
You Can Only Minimize It, Never Erase It
Even a program with zero visible side effects still generates heat and takes measurable time.
A truly side-effect-free program would be indistinguishable from one that never ran at all.
So the goal was never zero. It's fewer, and obvious.
Spread side effects evenly through every function, and every function becomes a suspect when something breaks. Fence them off in one recognizable place, and most of your code stays off the suspect list.
That's the payoff. A real function always returns the same output for the same input, the same guarantee a math equation gives you. When a bug shows up, you can usually rule out the pure code first. It behaves the same way every time. Side-effecting code is where state can differ between runs, which is exactly why bugs cluster there.
There's a second payoff too: a teammate reading a pure function doesn't have to check the rest of the codebase to know what it can touch. The signature already told them.
Same input, same output, every time. That guarantee has its own name, and it's worth pinning down next.
Reference
- MDN Glossary: Pure function - the formal definition this post's "direct input, direct output" rule is built on.
- JavaScript Pure Functions and Side Effects in 8 Minutes - a fast, concrete walkthrough of the same distinction with different examples.
Keep reading