Conditional compilation in Swift
What #if is in Swift, how it differs from a regular if, and when to use it: DEBUG, platforms, simulator, custom flags, and the difference with assert().
In the feature flags post we used #if DEBUG to wrap the active flag log. We put it in, it worked, and moved on. But I never explained what’s actually happening there — and it’s worth understanding, because #if in Swift is different from any other condition you use day to day.
It’s not an if. It doesn’t evaluate anything at runtime. What it does is tell the compiler which code to include and which to ignore based on the build configuration. That changes things quite a bit.
Compile-time vs runtime
A regular if evaluates a condition when the code runs. Both branches exist in the binary — the compiler includes them even if one is never taken.
A #if block works before that. The compiler reads the condition, and code that doesn’t meet it never makes it into the binary. It isn’t optimized away or ignored at runtime — it simply doesn’t exist.
// Both branches are in the binary, even if isDebug is always false in release
if isDebug {
logActiveFlags()
}
// In release, logActiveFlags() doesn't exist at all
#if DEBUG
logActiveFlags()
#endif
That matters when there’s code you definitely don’t want reaching production: logs with tokens, internal inspection tools, test data. With if, that code ships to the user even if it never runs. With #if DEBUG you remove it from the binary entirely.
#if DEBUG
The most common one. Swift activates it when you compile in Debug mode — the default when you hit ⌘R in Xcode. In Release it doesn’t exist.
#if DEBUG
print("[Auth] Token: \(token)")
#endif
Something that took me a while to understand: DEBUG isn’t something you define — Xcode activates it automatically based on the active build configuration. That has an important consequence: if you have a custom configuration like Staging, DEBUG won’t be active there unless you go to Build Settings → Swift Active Compilation Conditions and add it explicitly. More than once I ended up without logs in staging and couldn’t figure out why.
#if DEBUG vs assert()
Different tools, even if they sometimes cover similar ground. Worth knowing when to reach for each.
assert() evaluates a condition at runtime and crashes if it’s false. In release the compiler removes the assert — but the arguments you pass to it do compile, and if they have side effects those can run even without the assert firing. It’s easy to introduce a subtle bug that way.
assertionFailure() is the same but without a condition: it crashes whenever it’s reached. We used it in the @UserDefault from the feature flags post to signal a type mismatch — if code reaches that line in debug, it’s a bug that needs fixing.
#if DEBUG is more drastic than both: code inside it doesn’t compile in release, full stop. I use it when the entire block makes no sense outside of development — not as a guard against errors, but so it literally doesn’t exist in the final binary.
// Debug tool that shouldn't exist in production
#if DEBUG
func dumpFullState() -> String { ... }
#endif
// Verify an invariant that must always hold in development
assert(index < array.count, "index out of bounds")
// Path that should never be reached
assertionFailure("unhandled type: \(type)")
Platform checks
When the same code needs to run on more than one platform, #if os() and #if canImport() become essential. The most common case: importing different frameworks or defining types per system.
#if os(macOS)
import AppKit
typealias PlatformColor = NSColor
#elseif os(iOS)
import UIKit
typealias PlatformColor = UIColor
#endif
canImport does something more precise — instead of asking about the operating system, it asks directly whether a specific framework is available in the target:
#if canImport(AppKit)
// AppKit-specific code
#endif
For code shared across multiple targets, canImport tends to be more reliable than os(). It doesn’t assume what each platform has — it checks directly.
#if targetEnvironment(simulator)
This condition is true when code runs in the iOS or iPadOS simulator. Some hardware APIs simply don’t work there — the camera, certain sensors, some CoreBluetooth capabilities. In those cases, instead of letting the code fail, I prefer to substitute it explicitly:
#if targetEnvironment(simulator)
// mock data or UI placeholder
let photoData = UIImage(named: "mock-photo")
#else
// real camera or hardware access
startCameraSession()
#endif
Custom flags
You can define your own in Xcode: Build Settings → Swift Active Compilation Conditions. I set them up per build configuration:
Debug: DEBUG ANALYTICS_DISABLED
Staging: STAGING
Release: (empty)
In code:
#if STAGING
let apiURL = "https://staging.api.yourapp.com"
#else
let apiURL = "https://api.yourapp.com"
#endif
The advantage over an if with a constant is that the compiler removes the code that doesn’t apply. In release, the staging URL doesn’t exist even as a string in the binary — there’s no way to find it with a binary analyzer.
Combining them
You can use &&, ||, and ! to combine conditions. I use this often to scope checks to a specific platform and configuration:
#if DEBUG && os(macOS)
// only in debug builds on macOS
#endif
#if !DEBUG
// anything that isn't debug
#endif
When to use it and when not to
Conditional compilation is a tool for elimination, not organization. If you use it to structure business logic, the code gets hard to follow quickly — reading a file full of #if is exhausting.
It makes sense when the code in question shouldn’t exist in certain contexts: debug tools, platform-specific code, configuration that changes between environments. Less so for branching behavior inside the same business function.
A rule that works for me: if you deleted the #if block and the code inside it, would the app still be correct in that context? If yes, the #if makes sense. If not, it’s probably conditional logic that belongs in runtime.