Bye-bye Preprocessor Macros. Hello Active Compilation Conditions
In an Objective-C project, it is not uncommon to open Build Settings and see a long list of Preprocessor Macros. Preprocessor Macros are a feature inherited from C. Before the compiler compiles our code, a separate step - the preprocessor - runs through the source files and performs text substitution, replacing every occurrence of a macro with its defined value. So when we define API_DOMAIN as "https://platform.example.com" in our build settings, the compiler never sees API_DOMAIN - it just sees the raw string. The real power of Preprocessor Macros is that each build configuration can have its own list, so we can have a different value for API_DOMAIN depending on whether the project was built using the Debug or Release configuration.
So by using Preprocessor Macros we could write code like this:
@interface Environment : NSObject
// 1
@property (class, nonatomic, readonly) NSString *apiDomain;
@end
@implementation Environment
// 2
+ (NSString *)apiDomain {
return @API_DOMAIN;
}
@end
apiDomainis declared as a read-only class property, meaning it's accessed on the class itself rather than on an instance.- The getter is manually implemented since class properties aren't auto-synthesised. It returns the
API_DOMAINpreprocessor macro as anNSStringliteral.
The
@beforeAPI_DOMAINin the getter converts the C string macro into anNSStringliteral.
Sadly, Swift will never know the joys of Preprocessor Macros 😫.
Preprocessor Macrosdon't fit the design philosophy of Swift - there's no type checking, no scoping, and no namespacing withPreprocessor Macros.

This post will cover how we can migrate our Preprocessor Macros to Active Compilation Conditions.
The Swift Way
Active Compilation Conditions are boolean switches resolved at compile time. The compiler evaluates #if / #elseif / #else blocks and only compiles the code in the matching branch - the other branches don't exist in the final binary at all. Like Preprocessor Macros, they can be set per configuration; unlike Preprocessor Macros, they can't carry a value - they either exist or they don't. Where Preprocessor Macros performed text substitution, injecting values directly into our code, Active Compilation Conditions simply control which code paths get compiled.
For new projects, Xcode automatically populates
Active Compilation ConditionswithDEBUGfor theDebugconfiguration - other configurations don't get pre-populated. If you are migrating from an existing project, Xcode isn't as helpful - you will need to add them to all configurations.
To get the same functionality as we used to with Preprocessor Macros, we need to combine these flags with conditional logic to get the values we want:
// 1
enum Environment {
static func apiDomain() -> String {
// 2
#if DEBUG
return "https://development.platform.example.com"
#elseif RELEASE
return "https://platform.example.com"
#else
// 3
fatalError("Missing API domain")
#endif
}
}
Environmentis declared as an enum with no cases, making it act as a pure namespace that can't be instantiated.- These preprocessor directives filter out the branches that don't match the current active build configuration.
- If neither
DEBUGnorRELEASEis set, the app crashes with a descriptive message. We want to fail fast here so that the missing branch is picked up during development.
It's not as clean a solution as Preprocessor Macros; however, it's safer, and it still prevents the need to manually edit source files to change environment settings.
Safer because the compiler checks every branch rather than silently substituting text.
To see the complete working example, visit the repository and clone the project.