Feature flags with UserDefaults on macOS
How to use UserDefaults as a local feature flag system on macOS: a typed @propertyWrapper, clean reset, and an automatic active-flag log on launch.
There’s a moment in any app where you want to change something without recompiling. A debug overlay, the API environment, a feature that isn’t ready yet. Firebase Remote Config exists for that, but for a personal app it’s overkill from day one.
What I use: UserDefaults. You already have it, it already works.
The wrapper
The problem with raw UserDefaults is that you end up scattering magic strings across your whole codebase. I solved it with a @propertyWrapper:
@propertyWrapper
struct UserDefault<Value> {
let key: String
let defaultValue: Value
var wrappedValue: Value {
get {
guard let stored = UserDefaults.standard.object(forKey: key) else {
return defaultValue
}
guard let value = stored as? Value else {
assertionFailure("[FeatureFlag] '\(key)': expected \(Value.self), found \(type(of: stored))")
return defaultValue
}
return value
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}
var projectedValue: Self { self }
func reset() {
UserDefaults.standard.removeObject(forKey: key)
}
var isModified: Bool {
UserDefaults.standard.object(forKey: key) != nil
}
}
wrappedValue is the typed value you read and write directly. projectedValue exposes the wrapper itself via $, which gives access to reset() and isModified. The assertionFailure in the getter catches type mismatches in debug — if someone stored a string where you expected a bool, it tells you loudly. In release it silently returns the default.
How I organize it
Everything in one enum:
enum FeatureFlag {
@UserDefault(key: "debug_overlay", defaultValue: false)
static var debugOverlay: Bool
@UserDefault(key: "api_environment", defaultValue: "production")
static var apiEnvironment: String
@UserDefault(key: "beta_feed", defaultValue: false)
static var betaFeed: Bool
@UserDefault(key: "use_mock_data", defaultValue: false)
static var useMockData: Bool
}
Usage is clean:
FeatureFlag.debugOverlay // read → false
FeatureFlag.debugOverlay = true // enable
FeatureFlag.$debugOverlay.reset() // remove the key from disk
One thing worth clarifying: = false and .reset() are not the same. If you activated the flag with defaults write from the terminal, = false just writes false on top of that value. reset() removes the key entirely from disk, so the system falls back to the defaultValue in your code. If you want the “never touched” state back, use .reset().
Not forgetting what’s active
UserDefaults persists between sessions. You enable something, get distracted, and three days later the app behaves oddly and you have no idea why. It’s happened to me.
What I add is a launch log that lists active flags. isModified checks whether the key exists on disk — if it doesn’t, the system is reading from the code’s defaultValue and that flag doesn’t count:
extension FeatureFlag {
static func resetAll() {
$debugOverlay.reset()
$apiEnvironment.reset()
$betaFeed.reset()
$useMockData.reset()
}
#if DEBUG
static func logModified() {
let active = [
$debugOverlay.isModified ? "debugOverlay = \(debugOverlay)" : nil,
$apiEnvironment.isModified ? "apiEnvironment = \(apiEnvironment)" : nil,
$betaFeed.isModified ? "betaFeed = \(betaFeed)" : nil,
$useMockData.isModified ? "useMockData = \(useMockData)" : nil,
].compactMap { $0 }
guard !active.isEmpty else { return }
print("[FeatureFlag] Active flags:")
active.forEach { print(" · \($0)") }
}
#endif
}
And in the entry point:
@main
struct MyApp: App {
init() {
#if DEBUG
FeatureFlag.logModified()
#endif
}
}
Every time the app launches in debug you see in the console which flags are active. resetAll() is what I use when I want to wipe everything before a test build.
What I use them for
The one I turn on most is the debug overlay. I have a HUD that shows FPS, memory usage, and which endpoint is responding. I turn it on while debugging and off before screenshots. Without the flag that code would live commented out in some corner.
Switching environments also saves me time. Before I’d edit a constant and recompile. Now I change the flag and restart. useMockData goes on when the backend is down and I need to keep working on UI — it’s saved me a few afternoons.
How to activate them
The most convenient way: the Xcode scheme. In Product → Scheme → Edit Scheme → Arguments Passed On Launch I add -debug_overlay YES. UserDefaults reads those arguments at launch automatically, no extra code needed.
From the terminal, if the app is already installed:
open -a "MyApp" --args -debug_overlay YES -api_environment staging
Or with defaults write, which persists between restarts:
defaults write com.yourcompany.yourapp debug_overlay -bool YES
defaults write com.yourcompany.yourapp api_environment -string staging
With App Groups you swap UserDefaults.standard for UserDefaults(suiteName: "group.com.yourcompany.shared") and the flags are available to both the app and its extensions (handy if you have a Menu Bar companion or a Safari extension).
What it can’t do
Change behavior in production without an update. For that you need something remote. For my apps that’s not a requirement, so I live with it.