Co-Effects: The Hidden Input Problem
Under new management
The Gold Plan incident cost the food delivery company a small fortune in fish and a large one in dignity. There was a postmortem. There were action items. And there was a new hire: Asyncin Stackwalker, a promising young developer who, everyone agreed, would bring balance to the codebase. Refactoring the checkout is his first task.
enum UserPlan {
case basic
case gold
var deliveryFee: Decimal {
switch self {
case .basic: 5
case .gold: 0
}
}
}The user plan comes from the backend, and can be basic or gold. We know the delivery fee for each of them, so that's all we want to calculate the total for an order.
The checkout screen
Young Stackwalker's new checkout screen checks the user plan the moment it appears:
private var plan: UserPlan?
func checkoutDidAppear() async {
self.plan = try? await repository.fetchProfile().plan
}
func payButtonTapped() async {
let deliveryFee = plan?.deliveryFee ?? 0
let order = await pay(total: basket.subtotal + deliveryFee)
showOrderTracker(order: order.id)
}payButtonTapped()unit tests for gold userpayButtonTapped()unit tests for basic user- Integration test with mocked HTTP response
- End-to-end test (it doesn't assert the values on screen, only that the correct screen is open)
All green. Only thing is... there's a race condition and nobody knows that yet, not even our young Padawan. If the user taps the pay button before the fetch profile request finishes, plan is still nil. None of the tests above can catch this scenario — they all wait for the fetch to complete first. This is the kind of problem that shows up when things happen in an unexpected order.
A long time ago in an App Store far, far away....
#1 - Episode IV: A Nil Hope + Episode I: The Phantom Fee
Some users have slow internet connections, some users have fast fingers. Either case, it didn't take long until someone realised that tapping the pay button quickly would give free delivery, even on the basic plan. And when the profile request fails outright, that try? swallows the error and plan stays nil for the whole session — no fast fingers required.
#2 - Episode V: The Five Strikes Back
The fix is obvious, and the diff practically reviews itself: zero was never the fee — five is.
let deliveryFee = plan?.deliveryFee ?? 5Tests pass. Merged. Hotfix deployed, Apple reviewed in record time and the app is now fixed, right? Until furious subscribers of gold plan start calling support or cancelling their accounts because they are paying for delivery when they shouldn't. The app is now charging gold plan users for delivery!
#3 - Episode VII: The Force Unwraps
The cancellation wave triggers a proper investigation, and Young Stackwalker finally spots the real issue: the fallback. "This plan should never be nil, that ?? 0 is wrong."
private var plan: UserPlan!
func checkoutDidAppear() async {
self.plan = try? await repository.fetchProfile().plan
}
func payButtonTapped() async {
let deliveryFee = plan.deliveryFee
let order = await pay(total: basket.subtotal + deliveryFee)
showOrderTracker(order: order.id)
}And now we have crashes. Loads of crashes.
#4 - Episode VI: Return of the Fee + Episode III: Revenge of the Fifth
That implicit unwrap should not be there, clearly. Not any fallback ?? 0 or ?? 5. Now we see this clearly, and we know exactly how to fix it!
private var plan: UserPlan = .basic
func checkoutDidAppear() async {
self.plan = (try? await repository.fetchProfile().plan) ?? .basic
}And we are back to another wave of cancellations and furious subscribers calling the support, because again the gold plan users are being charged for delivery.
let deliveryFee = plan?.deliveryFee ?? 0 // #1 - episode IV: a nil hope + episode I: the phantom fee — Basic rides free
let deliveryFee = plan?.deliveryFee ?? 5 // #2 - episode V: the five strikes back — Gold pays anyway
let deliveryFee = plan!.deliveryFee // #3 - episode VII: the force unwraps — nobody pays, nobody eats
var plan: UserPlan = .basic // #4 - episode VI: return of the fee + episode III: revenge of the fifth — hello again, #2The team has now shipped every possible answer to a question that had no answer. And the type system was screaming this the whole time: at that line, UserPlan? offers exactly three moves — coalesce (pick a lie), force-unwrap (die), or remove the optional (make the lie permanent). The saga tried all three because the fourth option — say "I don't know yet" and refuse to render a price — isn't spellable at that line. The truth has no syntax there.
Where this bug lives
This little fiction tale may be a bit obvious for all of you Jedi out there. I bet you could see the problem and the solution very early without having to use the Force. But the thing about race conditions is that they aren't always so easy to see, especially when the code causing them doesn't all fit on one screen — separate files, separate classes, separate modules. And the code can get there in small steps: the first PRs are harmless, until one small, equally harmless change is the one that completes the race — which a code review wouldn't catch, and clearly neither would the tests.
However, Functional Programmers have a different perspective on this. Where most people would find the race condition and stop at the first "why?", an FP practitioner keeps asking, and reaches something deeper: the lack of purity — the enabler of this bug and many others.
Now hold this next to the previous article. Tod's previewGoldPlanTotal lied about what it did — it wrote to something outside its walls, and at least that left evidence: state, somewhere, had changed, and you could go find it. payButtonTapped() writes nothing. Pause the debugger at any instant and every value in memory is correct for that instant. There's no mutation to spot, no trace to diff. The function lies about something else entirely: what it needs. Its signature says it needs nothing; it actually needs plan — a value someone else fills in, and worse, it needs it at the right moment. The bug doesn't live in state — it lives in time, in the gap between a read and the write that should have beaten it there. And code review reviews space, not time.
A hidden write riding along with a function's output — the previous article — is a side effect. A hidden read sneaking into a function's input is the mirror image: a co-effect. Same crime, opposite direction. The side effect lies about what the function did; the co-effect lies about what the function depends on. And as we just watched for four deploys: same call, same visible arguments, different result, decided by milliseconds nobody can see on the page.
Meanwhile, in Berlin
The company survives, regroups, and goes international. Germany first — Berlin loves sushi. Katja, office manager at a startup in Kreuzberg, is ordering the summer-party catering: platters for forty people, €1.299,00. That's how her phone writes one thousand two hundred ninety-nine euros, and it's how she writes it too — in most of Europe the comma is the decimal separator and the dot groups thousands. The app shows exactly that. The app is right.
Under the hood, the payment provider's API wants the amount as a decimal string — "19.99", not 19.99. That's not bureaucracy, it's wisdom: a JSON number becomes a binary floating-point double in whoever parses it, and floats and money don't mix. Serious payment APIs ask for strings precisely so your euros survive the trip. Tod writes the request:
struct PaymentRequest: Encodable {
let orderId: String
let amount: String // API contract: decimal string — floats and money don't mix
}
PaymentRequest(orderId: order.id, amount: "\(order.total)")And the reviewer — sharp as ever after the last incident — leaves a comment: "String interpolation for a number? Use .formatted(), that's literally what it's for." Which is a fair cop. Tod amends, the diff looks cleaner than before, everyone feels good about code quality:
PaymentRequest(orderId: order.id, amount: order.total.formatted())CI is green. Of course it's green: the test amounts are things like 19.99, and the CI machines — like every machine in the building — are set to a dot-decimal region, so .formatted() produces "19.99" and every assertion passes honestly. The failing input isn't in the code, the tests, or the payload. It's in Katja's Settings app.
One string, two numbers
On Katja's phone, Decimal(1299).formatted() returns "1.299" — dot as thousands grouping, trailing zeros dropped, exactly how you write it in Berlin. So this goes on the wire:
{ "orderId": "SUSHI-40-PLATTERS", "amount": "1.299" }Now savour that string, because it's the whole article in five characters: "1.299" is byte-for-byte a valid spelling of two different numbers, a thousand apart, depending on which side of the wire reads it. And notice what that means for the backend: it doesn't need to be careless to get this wrong. The strictest, most defensive dot-decimal parser on the planet accepts "1.299" without a flicker — one point two nine nine, clean as you like. No error, no warning, no fallback taken. Every layer of the stack behaves perfectly according to its own dialect, and the money evaporates anyway.
The payment provider's confirmation page dutifully shows the amount back to Katja: 1.299. She reads it the only way she knows how: one thousand two hundred ninety-nine. Looks exactly right. She confirms. €1.30 is charged for €1.299,00 of sushi. And the rare customer who does squint at the payment screen and realise they're being asked for one euro thirty? They pay too. Nobody in recorded history has rung support to complain about a 99.9% discount. Once again the bug's only witnesses are the people it rewards — except this time there's no funny modal, no visible glitch, nothing to screenshot. The customer's screen is right, the payment page is plausible, the backend logs say "paid successfully", CI is green. The first person to see this bug sees it weeks later, in a spreadsheet: German revenue at 0.1% of forecast, every single order marked paid.
The input nobody wrote
Where did the locale get in? Nobody typed Locale anywhere. Here's .formatted()'s actual entry point:
// What order.total.formatted() resolves to:
FloatingPointFormatStyle<Double>(locale: .autoupdatingCurrent)The dependency is right there in the signature — as a default argument. This is the modern, value-typed, composable formatting API, and the co-effect survived modernisation by hiding in the one part of a signature nobody reads. It's arguably sneakier than the old NumberFormatter, which at least snapshots Locale.current when you create it: .autoupdatingCurrent tracks the Settings app live, so the same call with the same argument can return differently formatted strings within one session, if the user changes region mid-flight. A hidden input that can change between two identical calls — the purest co-effect Apple ships.
And the bitter joke of the code review: "\(order.total)" — the lazy-looking interpolation the reviewer rejected — was the correct code. Swift's description for numbers is deliberately locale-independent: dot separator, no grouping, identical on every device on Earth, precisely so that machine-facing strings stay machine-readable. The review comment raised the code quality and installed the bug. That's what makes co-effects the sneakiest bugs in review: the dangerous line doesn't slip past the reviewer — it's the line the reviewer asked for.
It's a whole family
Locale has siblings, and every one of them is an argument your functions receive without anyone writing it down. Date() hands your code the device clock — which the user can set, and will, the moment your free trial ends. Calendar.current decides what year it is: on a Thai device set to the Buddhist calendar it's currently 2569, which has convinced more than one card-expiry validator that every credit card on the planet expired five centuries ago. TimeZone.current decides when "today" ends, which gets interesting the day a customer orders dinner from a holiday two time zones away. UserDefaults smuggles in whatever was stored last week. None of them appear in any parameter list. All of them are inputs.
The fix: demand your inputs
Both acts, one disease, one cure. Start with the checkout. All four deploys were arguments about what to hallucinate when the hidden input was missing. So stop hallucinating — demand the input:
func summary(order: Order, plan: UserPlan) -> Summary {
Summary(subtotal: order.subtotal, deliveryFee: plan.deliveryFee)
}UserPlan, not UserPlan?. This function cannot run before the profile loads — not because of a careful timing argument, but because there is no value to call it with. The screen shows a spinner until a real UserPlan exists, which is the truth — "we don't know yet" finally has a spelling, and it lives where it belongs: at the boundary that fetches, not in the arithmetic. The window doesn't get smaller. It stops existing. The compiler joins the code review, and unlike the reviewer, it cannot be talked into "it's always loaded by then."
Now Berlin. Same question — is the locale an input? — but the honest answer here is: no, and it never was. A wire format is machine talking to machine; no human's regional preferences have any business in it. So the fix isn't a locale parameter — it's the absence of one. Interpolation, the boring code from before the review comment, is machine-format by design. If you want the decision visible instead of implicit, pin it and let the pin document the intent:
// Honest option 1: locale-independent by design
PaymentRequest(orderId: order.id, amount: "\(order.total)")
// Honest option 2: the pin, written down where review can see it
PaymentRequest(
orderId: order.id,
amount: order.total.formatted(
.number
.locale(Locale(identifier: "en_US_POSIX"))
.grouping(.never)
)
)Either an ambient value matters to the result — then it's a parameter, visible, chosen at the call site — or it doesn't matter — then it must be absent, not invisible. What's never on the menu is the versions this article buried: mattering invisibly.
Put the two articles side by side and the symmetry is the lesson. A side effect is a hidden output: the function's signature undersells what it did. A co-effect is a hidden input: the signature undersells what it needed. Both are the same lie told in opposite directions, and both have the same fix — make the signature tell the whole truth. Everything the function changes goes out through the return value; everything the function consults comes in through the parameter list. This move — take what you'd silently reach for and accept it as an argument instead — is the seed of a much bigger pattern called dependency injection, and where that leads is a story of its own. The first step is just an honest signature.
Guten Appetit, everyone!