About Bedbugs and Hidden Ninjas
A field guide to household pests
By now you've met both intruders. The ninja is the hidden output: a function that strikes something outside itself — a singleton, a file, a counter — and vanishes, leaving a signature that swears it was never there. The bedbug is the hidden input: it doesn't strike anything, it just bites you quietly, feeding on some ambient value you never invited into the function. One changes the world on the way out; the other smuggles the world in. Both lie through their signatures.
Here's the uncomfortable part: Apple's frameworks are a lovely, comfortable, beautifully decorated old hotel — and it has an infestation. Not because the engineers were careless, but because these APIs were designed decades ago to be convenient by default, and "convenient" meant reading your device's settings without asking. This article is the room-by-room inspection: where the pests nest, how they bite, and how to sleep soundly anyway.
Bedbugs ship with the furniture
The classic nest is the formatter. You bring one home from the initializer showroom, and it looks factory-fresh:
import Foundation
let instant = Date(timeIntervalSince1970: 1_784_853_000) // 24 July 2026, 00:30 UTC
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .short
formatter.string(from: instant) // ...well. It depends.Depends on what? On things no line of this code mentions: DateFormatter() walks out of its initializer pre-loaded with Locale.current, TimeZone.current, and Calendar.current — three bedbugs in the seams, installed at the factory. On the machine this article was written on, that call prints "24. July 2026 at 01:30" — German day-and-dot, English month name — because the author's region and language settings disagree with each other. The output isn't controlled by your code; it's controlled by the Settings app. Pin the hidden inputs and you can see just how much they were deciding:
func render(_ instant: Date, locale: String, timeZone: String) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .short
formatter.locale = Locale(identifier: locale)
formatter.timeZone = TimeZone(identifier: timeZone)
return formatter.string(from: instant)
}
render(instant, locale: "en_US", timeZone: "America/Los_Angeles") // "July 23, 2026 at 5:30 PM"
render(instant, locale: "de_DE", timeZone: "Europe/Berlin") // "24. Juli 2026 um 02:30"
render(instant, locale: "ja_JP", timeZone: "Asia/Tokyo") // "2026年7月24日 9:30"Look at the first two lines again, and not at the formatting — at the day. The same instant is 23 July in Los Angeles and 24 July in Berlin. If your voucher "expires on the 24th", Californian customers will watch it die a day early, and no bug report will ever contain enough information to explain why, because the failing input is the phone's timezone, and nobody thinks of their timezone as input. It's Katja's catering order all over again, except this time the bite draws blood on a calendar instead of a decimal separator.
One more, because this one is genuinely cruel: iOS users can flip the 12/24-hour switch in Settings, and that toggle quietly rewrites even an explicit dateFormat — your "HH:mm" can come back wearing "1:30 PM" anyway. Apple's own guidance for machine-readable dates is to pin the locale to en_US_POSIX, the one locale that promises to never follow local fashion.
And before you reach for the modern replacement: if you read article #3, you remember that the shiny .formatted() API still hides the same co-effect. It isn't completely better than NumberFormatter() or DateFormatter() — the bedbug just moved out of the initializer and into a default argument, locale: .autoupdatingCurrent, the part of a signature nobody reads. Its excess of convenience can be equally misleading — and, as Katja can tell you, very generous.
Is it the weekend yet? Depends where you're standing
Calendar.current deserves its own exhibit. Quick quiz: our instant above falls on Friday, 24 July 2026. Is that the weekend?
Calendar.current.isDateInWeekend(instant) // your guess is as good as your region'sTrick question — "the weekend" is not a fact about the date, it's a fact about the country. Pin the region and watch the same Friday change its mind:
func isWeekend(_ date: Date, region: String) -> Bool {
var calendar = Calendar(identifier: .gregorian)
calendar.locale = Locale(identifier: region)
calendar.timeZone = .gmt
return calendar.isDateInWeekend(date)
}
isWeekend(instant, region: "en_US") // false
isWeekend(instant, region: "de_DE") // false
isWeekend(instant, region: "he_IL") // true — weekend is Friday–Saturday
isWeekend(instant, region: "ar_EG") // trueA "don't send notifications on weekends" feature built on Calendar.current is a feature that behaves differently in Tel Aviv, Cairo, and Berlin — and was tested in exactly one of those. The same family of surprises hides in firstWeekday (weeks start on Sunday in the US, Monday in Germany), week-of-year arithmetic, and "same day" comparisons. None of it is wrong; all of it is input, and it never once appeared in a parameter list.
The clock and the dice
Two more bedbugs, so common they're practically domesticated. Date() hands your function the wall clock — an input that changes between two consecutive calls, as Referential Transparency: Swapping an Expression for Its Value will explore properly. And UUID() hands it a fresh roll of the dice. Both make a function unrepeatable: call it twice, get two different answers, and no test can ever pin down what "correct" was supposed to look like. They feel harmless because they're small. So are bedbugs.
Ninjas gather in dojos: .shared, .standard, .default
Now for the other species. Every one of these names is a dojo full of ninjas, reachable from any file in your codebase: UserDefaults.standard, FileManager.default, NotificationCenter.default, URLSession.shared, ProcessInfo.processInfo, Bundle.main. Reading from one mid-function is a bedbug — an input from nowhere. Writing to one is a full ninja strike: disk changes, observers fire, requests fly, and the function's signature still says (String) -> Int with a straight face. You met this pattern in the sushi incident — CurrentUser.shared was a home-made dojo, but Apple ships them pre-built, which makes reaching for one feel normal. That's exactly what makes them dangerous: nobody reviews a line that looks like everyone else's lines.
A ninja in the constructor: the horror show
Side effects are bad anywhere, but in an initializer they graduate from bad to horrendous. Behold, a compact museum of everything this article is about:
@MainActor
final class SessionTracker {
static let shared = SessionTracker()
let launchCount: Int
private init() {
let defaults = UserDefaults.standard
launchCount = defaults.integer(forKey: "launches") + 1
defaults.set(launchCount, forKey: "launches")
}
}Constructing this object reads global state, computes from it, and writes it back to disk. Creating a value changes the world — and because static let shared is lazy in Swift, that write happens whenever any code anywhere touches .shared for the first time. Not at launch. Not at a line you can point to. At first touch. Move an innocent if in some unrelated file and the "launch count" changes when — or whether — it increments. Your program's behaviour now depends on the order in which its own files happen to wake up, which is the kind of sentence that should require a stiff drink to type.
Real-world versions are everywhere: analytics SDKs that fire network requests the moment you initialize them, view models that subscribe to NotificationCenter in init, managers that read six defaults keys before you've asked them anything. A constructor has one job: construct. Take values in, store them, sit still. Anything else is a ninja hiding in the welcome mat.
The well-dressed smuggler
JSONDecoder looks like the responsible adult in the room — and, by default, it genuinely is. A pure init() that reads nothing from the device. Numbers travel in JSON's own locale-free grammar — there is no knob to localise them even if you wanted one. Dates, out of the box, are plain numbers too. Left alone, this is a certified bedbug-free room. But the strategy properties are an open door, and whatever you inject through them brings its own luggage. Watch what the responsible adult can be talked into carrying through customs:
struct Order: Decodable { let deliverOn: Date }
let json = Data(#"{"deliverOn": "2026-07-24"}"#.utf8)
let dayFormatter = DateFormatter()
dayFormatter.dateFormat = "yyyy-MM-dd"
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(dayFormatter)
let order = try? decoder.decode(Order.self, from: json)That dayFormatter still has its factory bedbugs — Locale.current and TimeZone.current — so the decoded Date differs by up to a full day depending on the phone it runs on: pin the formatter to Tokyo and to Los Angeles and the two "same" orders land sixteen hours apart. A bedbug inside a smuggler inside a decoder. The same applies to any shared, long-lived encoder or decoder whose strategies someone mutates later — configuration is mutable state like any other. When the payload is machine data, the honest options are an ISO 8601 timestamp with an explicit offset, or simply decoding the primitive — let deliverOn: String — and converting at the edge, where the timezone decision is somebody's explicit, reviewable choice.
The exterminator's toolkit
The treatment is the same one both previous articles prescribed, applied ruthlessly: whatever your code would silently reach for, hand it in as a parameter instead. For settings, pass the primitive values — a Locale, a TimeZone, a Calendar, a Date — and let the caller decide where they come from. A function that needs to know "now" should say so in its signature and receive one specific, frozen "now".
Sometimes one frozen moment isn't enough — a receipt printer needs a fresh timestamp and a fresh identifier for every receipt. Then you don't inject the value; you inject the factory — a closure that produces one on demand:
struct Receipt {
let id: UUID
let issuedAt: Date
let total: Decimal
}
func makeReceipt(total: Decimal, now: () -> Date, newID: () -> UUID) -> Receipt {
Receipt(id: newID(), issuedAt: now(), total: total)
}
// production: the real clock and the real dice
makeReceipt(total: 16.75, now: Date.init, newID: UUID.init)
// tests: freeze both, and every run is identical forever
let frozenClock = Date(timeIntervalSince1970: 1_784_853_000)
let fixedID = UUID()
makeReceipt(total: 16.75, now: { frozenClock }, newID: { fixedID })makeReceipt contains no bedbugs and no ninjas — every input walks in through the front door, and the signature finally tells the whole truth: a total, a clock, a way to mint identifiers. Production hands it Date.init and UUID.init and behaves exactly as before; tests hand it a frozen clock and loaded dice and become boringly, wonderfully repeatable. The rule of thumb: pass the value when one moment suffices, pass the () -> Date factory when time needs to keep flowing — and either way, the dependency is written down where a code review can see it. Threading two extra arguments through every call is a small ceremony, admittedly — there is an elegant way to pre-apply them once and carry them around, but that trick has a name, and it deserves its own article.
House spiders: the pests you've made peace with
Every home has one pest it's decided to tolerate. In code, that's print:
func totalPrice(_ prices: [Decimal]) -> Decimal {
print("summing \(prices.count) prices...") // just a little debug note
return prices.reduce(0, +)
}Harmless, surely. It changes no state, corrupts no data, survives no reboot. But purity isn't measured in damage — it's measured in honesty, and this function writes to a stream it never mentions. Whether that matters is decided entirely by context, and context is exactly what the function can't see. In an iOS app, that line is noise in a debug console that never ships: shrug. Move the same function into a command-line tool and stdout is the user interface — it's the product itself. Now yourtool prices | jq feeds "summing 2 prices..." into a JSON parser, the pipeline dies, and a "harmless" debug note has become a broken release. Same function, same line. The only thing that changed is where it was standing.
The tolerated-pest family is bigger than print. A log statement stamps Date() on its way through — a bedbug riding on a house spider. An analytics call is "just one beacon", which is to say: a network request your signature never mentioned. A private memoization cache is the subtlest of all — same inputs still produce the same outputs, so Referential Transparency: Swapping an Expression for Its Value survives, but the function now mutates shared storage, and "benign" quietly acquires footnotes about thread safety and memory growth. None of these will page you at 3 a.m. this week. All of them are functions whose signatures no longer tell the whole truth, and there is no official list of effects small enough to be exempt — there are only signatures that confess and signatures that don't.
The treatment is one you already own. This article handed functions their hidden inputs as () -> Date — a value from nowhere, delivered through the front door. The same trick runs in reverse for outputs to nowhere:
func totalPrice(_ prices: [Decimal], log: (String) -> Void) -> Decimal {
log("summing \(prices.count) prices...")
return prices.reduce(0, +)
}
totalPrice([12.25, 4.50], log: { print($0) }) // debug build: chatty
totalPrice([12.25, 4.50], log: { _ in }) // CLI release: silent, stdout stays cleanThe function no longer decides where its chatter lands — the caller does, in writing. Debug builds pass print, the CLI passes a stderr writer or nothing at all, and tests can collect every message into an array and assert on it. The signature confesses everything.
None of this makes the hotel less lovely. Apple's frameworks are excellent at what they do — you just want the pests declared at check-in instead of discovered at midnight. Read the ambient world in exactly one place, at the edge of your program, and hand everything inward as honest parameters from there.
Sleep tight — and don't let the bedbugs byte.