Kategori: Uncategorized

  • Building a Feature Flag System with Swift Macros and Property Wrappers

    Feature flags are one of those things that start simple and slowly take over your codebase. You add one, then another, and before you know it you’re maintaining a growing list of keys, default values, and registration boilerplate that all need to stay in sync. Every new flag means touching multiple places — and every touch is a chance to mess something up.

    Swift macros and property wrappers can fix this. Macros let the compiler generate the repetitive registration code, while a property wrapper turns consuming a flag into a single line that’s reactive out of the box. In this post, I’ll walk through building both — a macro that eliminates flag declaration boilerplate, and a property wrapper that eliminates usage boilerplate — and wire them together into a complete feature flag system.

    The Problem: Boilerplate That Scales Linearly

    Here’s what a feature flag setup tends to look like in practice — no frameworks, just raw Swift:

    final class FeatureFlags: ObservableObject {
        static let shared = FeatureFlags()
        
        // Every flag needs a key, a default, and a computed accessor
        private let defaults: [String: Bool] = [
            "greenBackground": false,
            "showRemoteConfigList": true,
            "newOnboarding": false,
            "darkModeOverride": true
        ]
        
        @Published private var serverFlags: [String: Bool] = [:]
        
        func isEnabled(_ key: String) -> Bool {
            serverFlags[key] ?? defaults[key] ?? false
        }
        
        // Convenience accessors — one per flag, manually kept in sync
        var greenBackground: Bool { isEnabled("greenBackground") }
        var showRemoteConfigList: Bool { isEnabled("showRemoteConfigList") }
        var newOnboarding: Bool { isEnabled("newOnboarding") }
        var darkModeOverride: Bool { isEnabled("darkModeOverride") }
        
        // Also need a list of all keys for debugging/admin screens
        static let allKeys = ["greenBackground", "showRemoteConfigList", "newOnboarding", "darkModeOverride"]
        
        func fetchFromServer() {
            // ...
        }
    }

    And to actually consume a flag in SwiftUI, you’d need to observe the whole container as a @StateObject, trigger the fetch manually, and remember which property maps to which flag:

    struct ContentView: View {
        @StateObject private var flags = FeatureFlags.shared
        
        var body: some View {
            VStack {
                Text("Hello")
            }
            .background(flags.greenBackground ? Color.green : Color.gray)
            .onAppear {
                flags.fetchFromServer()
            }
        }
    }

    Every flag lives in three places: the defaults dictionary, a convenience accessor, and the allKeys array. Add a new flag? Touch all three. Rename one? Hope you remembered to update the string key. Forget to add it to allKeys? Your debug screen silently misses it. On top of that, the flag definitions (names and defaults) are tangled up with the fetching logic, the published state, and the convenience accessors — all in one class. At a glance, it’s hard to tell which flags exist and what their defaults are without reading through the entire implementation. The whole thing scales linearly in the worst way — more flags, more noise.

    Now imagine this instead:

    // Define your flags — the macro generates the rest
    @FeatureFlagContainer
    final class FeatureFlags {
        let greenBackground = false
        let showRemoteConfigList = true
    }
    
    // Use them in SwiftUI — the property wrapper handles fetching and reactivity
    struct ContentView: View {
        @FeatureFlag(\.greenBackground) var greenBackground
    
        var body: some View {
            VStack {
                Text("Hello")
            }
            .background(greenBackground ? Color.green : Color.gray)
        }
    }

    That’s it. The macro generates all the FeatureFlagKey boilerplate from simple let declarations, and the property wrapper handles fetching, observing, and defaulting. Two Swift features, each solving half of the problem.

    Let’s build both, starting with the macro.

    Building the Macro

    To create this, start a new Swift Macro package in Xcode (File → New → Package → Swift Macro). First, declare the macro interface:

    @attached(member, names: arbitrary)
    public macro FeatureFlagContainer() = #externalMacro(
        module: "FeatureFlagMacros",
        type: "FeatureFlagsMacro"
    )

    There are two kinds of macros in Swift: freestanding (can go anywhere) and attached (modify the type they’re applied to). Ours is attached — it inserts additional member declarations into the class it decorates. The names: arbitrary parameter tells the compiler we’ll be generating members with names it can’t predict ahead of time.

    Now for the implementation. The macro conforms to MemberMacro and receives the full syntax tree of the annotated type:

    import SwiftSyntax
    import SwiftSyntaxBuilder
    import SwiftSyntaxMacros
    
    public struct FeatureFlagsMacro: MemberMacro {
        public static func expansion(
            of node: AttributeSyntax,
            providingMembersOf declaration: some DeclGroupSyntax,
            conformingTo protocols: [TypeSyntax],
            in context: some MacroExpansionContext
        ) throws -> [DeclSyntax] {
            var flagKeys: [String] = []
            var transformedMembers: [DeclSyntax] = []
    
            for member in declaration.memberBlock.members {
                guard let varDecl = member.decl.as(VariableDeclSyntax.self),
                      varDecl.bindingSpecifier.tokenKind == .keyword(.let),
                      let identifier = varDecl.bindings.first?.pattern.as(IdentifierPatternSyntax.self),
                      let initializer = varDecl.bindings.first?.initializer?.value else {
                    continue
                }
    
                let keyName = identifier.identifier.text
                flagKeys.append(keyName)
    
                transformedMembers.append(
                    DeclSyntax(
                        """
                        public let \(raw: keyName) = FeatureFlagKey(key: "\(raw: keyName)", defaultValue: \(raw: initializer.description))
                        """
                    )
                )
            }
    
            let allCasesFunction = DeclSyntax(
                """
                public func allCases() -> [String: NSObject] {
                    return [
                        \(raw: flagKeys.map { key in
                            "\"\(key)\": \(key).defaultValue.asRemoteConfigValue"
                        }.joined(separator: ",\n"))
                    ]
                }
                """
            )
            transformedMembers.append(allCasesFunction)
    
            let nestedKeysStruct = DeclSyntax(
                """
                public struct Keys {
                    \(raw: transformedMembers.map(\.description).joined(separator: "\n"))
                }
                """
            )
    
            return [nestedKeysStruct]
        }
    }

    Filtering Members

    The guard statement at the top is doing the heavy lifting:

    guard let varDecl = member.decl.as(VariableDeclSyntax.self),
          varDecl.bindingSpecifier.tokenKind == .keyword(.let),
          let identifier = varDecl.bindings.first?.pattern.as(IdentifierPatternSyntax.self),
          let initializer = varDecl.bindings.first?.initializer?.value else {
        continue
    }

    It filters for let declarations with an initializer — exactly the pattern we use to define flags. Anything else (methods, computed properties, vars) gets skipped. We extract both the identifier (the property name) and the initializer value (the default).

    Generating Code

    For each flag, the macro generates a FeatureFlagKey instance using the property name as both the variable name and the string key:

    let keyName = identifier.identifier.text
    flagKeys.append(keyName)
    
    transformedMembers.append(
        DeclSyntax(
            """
            public let \(raw: keyName) = FeatureFlagKey(key: "\(raw: keyName)", defaultValue: \(raw: initializer.description))
            """
        )
    )

    So let greenBackground = false becomes public let greenBackground = FeatureFlagKey(key: "greenBackground", defaultValue: false). The key string is always derived from the property name, which eliminates the mismatch risk entirely.

    The Nested Struct Workaround

    One important limitation of Swift macros: they can only add code, never remove or modify existing code. That means we can’t replace the original let declarations in-place. Instead, we wrap all generated members in a nested Keys struct:

    let nestedKeysStruct = DeclSyntax(
        """
        public struct Keys {
            \(raw: transformedMembers.map(\.description).joined(separator: "\n"))
        }
        """
    )

    This keeps the generated FeatureFlagKey properties separate while still being easily accessible through FeatureFlags.Keys.

    Registering the Plugin

    Finally, register the macro in your package’s main.swift:

    import SwiftCompilerPlugin
    import SwiftSyntaxMacros
    
    @main
    struct MacrosPlugin: CompilerPlugin {
        let providingMacros: [any Macro.Type] = [
            FeatureFlagsMacro.self
        ]
    }

    Making It Reactive with a Property Wrapper

    The macro handles registration, but we also want feature flags to update the UI automatically when their values change at runtime. This is where a property wrapper comes in.

    If you haven’t worked with property wrappers before: the @propertyWrapper attribute tells Swift that a type wraps additional behavior around a property. When you annotate a property with @FeatureFlag, you’re not just storing a boolean — you’re embedding logic to manage that value, like subscribing to updates.

    @propertyWrapper
    class FeatureFlag: ObservableObject {
        private let key: String
        private let defaultValue: Bool
        private var cancellables = Set<AnyCancellable>()
        
        @Published var wrappedValue: Bool
        var projectedValue: FeatureFlag { self }
        
        init(_ keyPath: KeyPath<FeatureFlags, FeatureFlagKey>) {
            self.key = FeatureFlags.shared[keyPath: keyPath].key
            self.defaultValue = FeatureFlags.shared[keyPath: keyPath].defaultValue
            self.wrappedValue = FeatureFlagFetcher.shared.isEnabled(key) ?? defaultValue
            listenForUpdates()
        }
        
        private func listenForUpdates() {
            FeatureFlagFetcher.shared.$serverFlags
                .receive(on: DispatchQueue.main)
                .sink { [weak self] flags in
                    guard let self = self else { return }
                    self.wrappedValue = flags[self.key] ?? self.defaultValue
                }
                .store(in: &cancellables)
        }
    }

    Key Paths for Type-Safe Initialization

    The initializer takes a key path rather than a string:

    init(_ keyPath: KeyPath<FeatureFlags, FeatureFlagKey>) {
        self.key = FeatureFlags.shared[keyPath: keyPath].key
        self.defaultValue = FeatureFlags.shared[keyPath: keyPath].defaultValue
        self.wrappedValue = FeatureFlagFetcher.shared.isEnabled(key) ?? defaultValue
        listenForUpdates()
    }

    A key path in Swift is a type-safe reference to a property. By requiring a KeyPath<FeatureFlags, FeatureFlagKey>, the compiler verifies at build time that the flag you’re referencing actually exists. Usage looks like this:

    @FeatureFlag(\.greenBackground) var greenBackground

    The \. prefix is shorthand for a key path rooted at FeatureFlags. The wrapper then uses it to fetch both the flag’s key string and default value from the shared container.

    The Projected Value

    There’s one more piece worth understanding:

    var projectedValue: FeatureFlag { self }

    In Swift, property wrappers can expose a projected value via the $ prefix. By returning self, we let SwiftUI (or anything else) access the full FeatureFlag instance — not just the raw boolean. This is what makes $greenBackground work as an ObservableObject that SwiftUI can subscribe to.

    When to Apply Flag Changes

    Before we wire everything up, it’s worth pausing on a design decision that our property wrapper makes for us: when do fetched flag values actually take effect? There are three common strategies, each with real trade-offs:

    Fetch before launch. The app waits for fresh flag values before showing any UI. This guarantees consistency — every view renders with the correct flags from the start. The downside is a potentially noticeable delay on the launch screen, especially on slow connections. For apps where showing the wrong state for even a moment is unacceptable, this is the safest bet.

    Fetch in the background, apply immediately. This is what our implementation does. The app launches with defaults, fetches in the background, and updates the UI the moment new values arrive. It’s responsive, but it can cause visible jumps — a button might appear, a color might change, or worse, a feature that was briefly available gets yanked away mid-session. For cosmetic flags this is usually fine. For flags that gate critical flows, it can be jarring or even break user expectations.

    Fetch in the background, apply on next launch. The app fetches fresh values and stores them locally, but only applies them the next time the app starts. No UI jumps, no mid-session surprises. The trade-off is staleness — users always see flag values that are at least one session old. This is the most common approach in production apps, and arguably the safest default for most use cases.

    Our property wrapper uses the second approach because it’s the most interesting to demonstrate — you can actually see the flags update in real time. Adapting it for the third approach is straightforward: instead of subscribing to live updates, read cached values at init time and write fetched values to UserDefaults for the next launch.

    Putting It All Together

    With the macro and property wrapper in place, we need a fetcher to simulate retrieving flags from a server:

    final class FeatureFlagFetcher: ObservableObject {
        static let shared = FeatureFlagFetcher()
        @Published private(set) var serverFlags: [String: Bool] = [:]
        
        func isEnabled(_ key: String) -> Bool? {
            return serverFlags[key]
        }
    
        func fetchFromServer() {
            DispatchQueue.global().asyncAfter(deadline: .now() + 3) {
                DispatchQueue.main.async {
                    self.serverFlags = [
                        "greenBackground": true,
                        "showRemoteConfigList": true
                    ]
                }
            }
        }
    }

    And a SwiftUI view to see everything in action:

    struct ContentView: View {
        @FeatureFlag(\.greenBackground) var greenBackground
        @FeatureFlag(\.showRemoteConfigList) var showRemoteConfigList
        @State private var isShowingFeatureList = false
    
        var body: some View {
            VStack(spacing: 20) {
                Text("Feature Flag Demo")
                    .font(.headline)
                    .foregroundColor(.white)
                            
                if showRemoteConfigList {
                    Button("Show Feature Flags") {
                        isShowingFeatureList.toggle()
                    }
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .clipShape(Capsule())
                }
            }
            .padding()
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(greenBackground ? Color.green : Color.gray)
            .ignoresSafeArea()
            .onAppear {
                FeatureFlagFetcher.shared.fetchFromServer()
            }
            .sheet(isPresented: $isShowingFeatureList) {
                FeatureFlagListView()
            }
        }
    }

    Run the app. For the first few seconds, you’ll see the default state (gray background, no button). Once the simulated fetch completes, the background turns green and the feature flag list button appears — all driven by the property wrapper reacting to the fetcher’s published values.

    Wrapping Up

    By combining a macro for registration, a property wrapper for reactivity, and a simple fetcher for the data source, we’ve built a feature flag system where adding a new flag is a single line of code. No duplicate key strings, no manual array maintenance, no risk of things falling out of sync.

    The macro pattern extends well beyond feature flags — anywhere you have a list of similar declarations with repetitive boilerplate is a good candidate. The key constraint to remember is that macros can only add code, never modify or remove it, which is why the nested struct pattern comes in handy.

    Happy coding!