Side Effects: The Hidden Output Problem
Sushi, anyone?
It's a beautiful day in the neighbourhood and our hero wants to make the missus happy, and tonight poor Nemo will help him with that. He grabs that greasy iPhone with a huge crack on the top corner, opens the app and tap-tap-taps those delicious nigiri, sashimi, and maki into his basket. It's gonna cost 30 quid plus a delivery fee, straight out of his hard-earned paycheck, but hey, happy wife, happy life. He may not know it yet, but his luck is about to change, and all because his sushi is pure and clean, but the code in that app is not. The app keeps track of him with a variable, like this:
@MainActor
final class CurrentUser {
static let shared = CurrentUser()
var deliveryFee: Decimal = 5 // Basic plan: delivery isn't free
var discount: Decimal = 0
}
func total(subtotal: Decimal, discount: Decimal, deliveryFee: Decimal) -> Decimal {
subtotal * (1 - discount) + deliveryFee
}
total(subtotal: 30, discount: CurrentUser.shared.discount, deliveryFee: CurrentUser.shared.deliveryFee) // 35.0He doesn't subscribe to any fancy plan that requires recurring fee, he rarely uses this app anyway. So in total he pays £30 for the food, £5 for delivery, and no discount — that's the Basic plan. total itself only ever sees three numbers; it has no idea a CurrentUser exists, and that's how it's supposed to be.
Something smells fishy
Marketing wants a modal screen: "See what Gold Plan gets you — free delivery and 5% off your first order." On top of his fancy gamer chair, Tod types on his mechanical keyboard the obvious helper, reusing total since it already knows the math:
@MainActor
func previewGoldPlanTotal(subtotal: Decimal) -> Decimal {
CurrentUser.shared.deliveryFee = 0
CurrentUser.shared.discount += 0.05
return total(subtotal: subtotal, discount: CurrentUser.shared.discount, deliveryFee: CurrentUser.shared.deliveryFee)
}
previewGoldPlanTotal(subtotal: 30) // 28.5 — looks right, modal shipsRead that function the way a reviewer would. Its name says preview. Tod even used the Decimal type, how fancy he is, mommy would be so proud!! It works on his machine. It works in CI. His PM tested the app, opened the modal, everything matched. Ship it — it's not like it's Friday, nothing bad's going to happen. But let me tell you what: Tod's about to make a lot of sushi lovers very happy tonight!
There's no such thing as a free lunch. Or is there?
Meanwhile our beloved customer finished his order but out of curiosity taps on some banner talking about Gold Plan and the screen that opens tells him that instead of £35, he could pay £28.5. On opening the modal, the app ran this to calculate what to show in the preview screen, exactly as Tod wrote it:
previewGoldPlanTotal(subtotal: 30) // 28.5 — user opens the modal"Neeeh, not today", he closes the modal and goes back to the checkout. But there's something curious: the price on checkout now includes the discount and free delivery. He is confused, "Did I subscribe by mistake?" He opens up the modal again, just to be sure, and then things get even more strange.
previewGoldPlanTotal(subtotal: 30) // 27.0 — closes it, opens it again out of curiosityThe price now dropped to £27.0, and free delivery. He closes the modal again, and taps it one more time, and now the price is £25.5. Then £24.0. Then £22.5. To be sure he is not having some kind of hallucination, he calls his best pal, Gil, and asks him to try it on his phone. Gil says: "no, dude, it says 5% off, here, 28.5 pounds." But then he tries again and:
previewGoldPlanTotal(subtotal: 30) // 28.5
previewGoldPlanTotal(subtotal: 30) // 27.0
previewGoldPlanTotal(subtotal: 30) // 25.5
previewGoldPlanTotal(subtotal: 30) // 24.0
previewGoldPlanTotal(subtotal: 30) // 22.5
previewGoldPlanTotal(subtotal: 30) // 21.0Thirteen taps later, he decides to go ahead with the checkout, just to see what happens. At this point, the basket is almost free. After 20 min to find his credit card and type those long numbers, finally the order is placed and the food arrives for almost no cost.
// taps "Place order" — still on the Basic plan
total(subtotal: 30, discount: CurrentUser.shared.discount, deliveryFee: CurrentUser.shared.deliveryFee) // 1.5 (95% discount)Each open of the modal left a mark — it didn't just compute a number, it added another 5% to something that outlives the call. Nineteen curious taps, and free delivery plus 95% off ride along into a Basic-plan checkout that never should have seen either. All that glitters is not Gold — some of it's just a stale value sitting in a singleton. Our hero will do what any decent person would do in this situation: he will call all his friends and tell them about this amazing deal he found out. Later he learned from Lucas that he'd ordered a huge Indian meal, and the app gave him £50 for it — though Lucas had to open the damn modal more times than his iPhone's battery could handle.
Notice what broke, formally: that last line is character-for-character the same call as the one at the very top of this article — same subtotal, same expressions for discount and deliveryFee — and it returns a different number. total is exactly as pure as it looks: three explicit numbers in, one number out, no exceptions, ever. What changed is what those two expressions evaluate to, because a function that ran in between — one whose name and signature promised a harmless preview — left the world edited behind it.
What "side effect" means here
previewGoldPlanTotal reaches into an object that outlives the call and mutates it. That mutation is invisible in its type: (Decimal) -> Decimal promises a number and delivers the right one, while the real damage travels through a channel no signature mentions. That's a side effect: a hidden, mutated output riding along with the visible one. The function doesn't lie about what it returns — it lies about what it did.
This has nothing to do with CurrentUser being a class, or @MainActor, or whatever. It would help to make CurrentUser immutable — a struct behind a let — but that doesn't address the real issue: previewGoldPlanTotal trying to write to something it had no business writing to in the first place. Some languages forbid a value from being both mutable and shared at the same time, which is one way of dealing with the problem — but that only treats a symptom, not the disease. It helps, of course. The real issue is that the function lies: if you couldn't see its implementation, you'd never guess the damage it's doing to the world outside its own parameter list.
This story only broke one way: a function wrote to something it had no business writing to. The mirror-image mistake — a function that quietly reads something it never declared as a parameter — is just as easy to write and just as invisible on the page. That's next.
The pure fix: preview by calculating, not rehearsing
A preview is a calculation, not a rehearsal. total was never the problem — it's already a pure function of three explicit numbers. The fix is to stop previewGoldPlanTotal from reaching for CurrentUser.shared at all: Gold's discount and delivery fee are known up front, not state to stage and read back:
func previewGoldPlanTotal(subtotal: Decimal) -> Decimal {
total(subtotal: subtotal, discount: 0.05, deliveryFee: 0)
}
previewGoldPlanTotal(subtotal: 30) // 28.5
previewGoldPlanTotal(subtotal: 30) // 28.5 — same every time, nothing accumulates
total(subtotal: 30, discount: CurrentUser.shared.discount, deliveryFee: CurrentUser.shared.deliveryFee) // 35.0 — Basic, untouchedThe modal gets its number, the account keeps its state, and applying Gold for real becomes what it always should have been: a deliberate, visible write when the customer actually upgrades — not a leftover from opening a modal. And there's no compiler trick to fall back on here either: no struct, no access-control tweak makes a globally-reachable mutable variable safe to write to from a function whose whole job was supposed to be read-only. The only fix is the one above — depend on nothing you didn't ask for as a parameter.
The principle is the same as ever: information that used to travel through a hidden channel — a shared object edited on the sly — now travels through visible ones, arguments in and a return value out. A function that computes an answer shouldn't change the world to find it.
Happy sushi, everyone!