Contain It: Wrap It, or Adapt Around It
Two techniques for a side effect you can't extract out of a function: wrapping it in a smaller scope, or saving and restoring state around it.
Extracting the impurity works when you can cleanly split "compute this" from "affect the world." Sometimes you can't. The side effect is woven too tightly into the logic to pull apart.
There's still something you can do: shrink how much of your program that side effect can reach. This is what "contain the impurity" means: not removing it, making its blast radius smaller.
A Function That Mutates a Shared Array
Say your cart keeps its items sorted by price, and inserting a new one uses this:
let cartItems = [];
function insertSortedByPriceDescending(item) {
let i = 0;
while (i < cartItems.length && cartItems[i].price >= item.price) i++;
cartItems.splice(i, 0, item);
}insertSortedByPriceDescending reaches out and mutates cartItems, a module-level array. Call it from anywhere in your codebase, and that same shared array changes. The side effect's reach is the entire module.
Technique One: Wrap It in a Smaller Scope
You can't easily turn this into a pure function, insertion sort by its nature rearranges something. But you can control what it's allowed to rearrange.
function getSortedCart(originalItems, newItem) {
let cartItems = [...originalItems];
function insertSortedByPriceDescending(item) {
let i = 0;
while (i < cartItems.length && cartItems[i].price >= item.price) i++;
cartItems.splice(i, 0, item);
}
insertSortedByPriceDescending(newItem);
return cartItems;
}This looks like a small change. It isn't.
The natural instinct is to just call the old function from inside a new wrapper. That wouldn't work. A function's free variables resolve to wherever that function was originally defined, not wherever it happens to get called from. This is the same mechanism behind closures, which get their own full post later in this series. If insertSortedByPriceDescending stayed at the module level, calling it from inside getSortedCart would still reach out and mutate the module-level cartItems, exactly as before.
Moving the function's definition inside getSortedCart is what makes the difference. Its cartItems reference now resolves to the local copy declared right above it, made fresh on every call, not the shared one sitting outside.
Call getSortedCart(originalItems, newItem), and from the caller's point of view, nothing outside changed. You didn't remove the side effect, you shrank the room it's allowed to make a mess in. Same surface-area idea from two posts ago, just aimed at impurity you can't get rid of instead of a value you're trying to trust.
When Wrapping Runs Out
Wrapping only works when you control the function doing the mutating, since you need to move its definition inside the wrapper.
Say your sort also depends on a third-party pricing SDK, one you can't edit or redefine:
const PricingSDK = {
threshold: 100,
isBelowThreshold(amount) {
return amount < this.threshold;
},
};
function insertSortedByPriceDescending(item) {
PricingSDK.threshold = item.price;
let i = 0;
while (i < cartItems.length && !PricingSDK.isBelowThreshold(cartItems[i].price)) i++;
cartItems.splice(i, 0, item);
}This function now mutates two things outside itself: cartItems, and PricingSDK.threshold. You can't move PricingSDK anywhere. It's not yours.
Technique Two: Save It, Run It, Put It Back
There's a second technique for exactly this case: let the impurity happen, but record everything it's about to disturb, and restore it all afterward.
function getSortedCartAdapter(originalItems, newItem) {
const originalThreshold = PricingSDK.threshold; // 1. save
cartItems = [...originalItems]; // 2. set up
insertSortedByPriceDescending(newItem); // 3. run the impure code
const result = cartItems; // 4. capture
PricingSDK.threshold = originalThreshold; // 5. restore
return result; // 6. return
}For the entire duration of this function's body, cartItems and PricingSDK.threshold are genuinely being mutated. That part is real.
The call site behaves pure from the outside, even though what happens inside is not pure at all.
Call getSortedCartAdapter with the same inputs, and you always get the same output, and nothing about the rest of the program is different afterward. Everything disturbed gets put back before it returns.
This won't scale to everything. Restoring an entire database or the whole DOM this way is rarely worth the effort. For a couple of variables you don't own, it's a real, usable tool.
The Full Order of Options
Put both posts together, and there's an actual order worth trying, from best to last resort:
- Write it pure from the start, if the design allows it.
- Refactor an existing impure function to be pure, if it's not too tangled.
- Extract the impurity, splitting computation from the side effect.
- Wrap it, if you own the function doing the mutating.
- Adapt around it, saving and restoring state, if you don't.
- If none of those work, at least make it obvious. Name the function honestly, comment it, keep it in one predictable place. A future reader debugging your code needs to know exactly where to look.
One Clarification Worth Making Precise
A question worth settling here: does reassigning a parameter inside a function count as a side effect?
function useLocalCopy(items) {
items = [...items]; // reassigns the local parameter binding
items.push('discount-applied');
return items;
}items = [...items] only changes what the local items binding points to. Nothing outside this function can see that reassignment happen. This is not a side effect.
Compare that to mutating the array the caller actually handed you:
function mutatesCaller(items) {
items.push('discount-applied'); // mutates the caller's own array, by reference
}Same-looking parameter, completely different outcome. mutatesCaller reaches through the reference it was given and changes something the caller still holds onto. That's the real side effect: mutating what a reference points to, not reassigning which value a local variable holds.
That distinction closes out this deep dive into function purity: what a function is, what it's allowed to touch, and what to do when it can't stay clean. Next, this series turns to a more practical question, shaping the inputs a function actually receives.
Try It Yourself
Both techniques from this post are worth practicing on your own code, not just reading about.
- Find a function that sorts an array in place,
array.sort()without copying first is a common one, and wrap it so calling it behaves purely from the outside. - Find (or imagine) a function that also flips a setting on some shared object or third-party library while it runs, then contain it with the save-and-restore adapter instead.
If you can write both and still trust the call site completely, you've got both techniques down.
Keep reading