Feature configuration with Swift macros

This article describes how we made the iOS app feature configuration system more ergonomic by using Swift’s macros. We take a framework with repetitive, hard-to-understand declarations and manual mocking and eliminate all of these weaknesses.

This is a follow-up to a previous article, which describes the system in depth.

Current implementation

There are three steps that need to be taken for a new feature configuration to be usable:

  1. Declare the feature type
// feature flag declaration
struct IsProfileCreditEnabled: FeatureFlag, RemotelyConfigurable {
    static let remoteConfigurationKey = "is_profile_credit_enabled"
    static let defaultValue = false
}

// A/B test declaration with a custom set of values
struct RateUsFormVisibility: ABTest, RemotelyConfigurable {
    enum Value: ABTestValue {
        case none = "ab_profile_feedback_item_none"
        case hidden = "ab_profile_feedback_item_hidden"
        case allUsers = "ab_profile_feedback_item_all_users"
        case signedUsers = "ab_profile_feedback_item_signed_users"

        static let enabledValues: Set<Value> = [.allUsers, .signedUsers]
    }

    static let remoteConfigurationKey = "ab_profile_feedback_item"
}
  1. Register the value in the package’s configuration container struct
struct ProfileFeatureConfiguration: FeatureConfigurationDescribing {

    struct FeatureFlags {
	      // ...
        // Add the new feature flag property
        let isProfileCreditEnabled = IsProfileCreditEnabled()
    }

    struct ABTests {
	      // ...
        // Add the new A/B test property
        let rateUsFormVisibility = RateUsFormVisibility()
    }

    let featureFlags = FeatureFlags()
    let abTests = ABTests()

    let accessor: any FeatureConfigurationAccessing
}

// FeatureConfigurationDescribing uses @dynamicMemberLookup
// making the following possible:

configuration.isProfileCreditEnabled // returns Bool
configuration.rateUsFormVisibility // returns RateUsFormVisibility.Value
  1. Register that struct in the root feature configuration resolver so that it receives values from remote config. This part isn’t relevant and thus not shown.

While this system worked, it had some issues:

  • it was unnecessarily verbose
  • the configuration registration could be forgotten
  • while using @dynamicMemberLookup was elegant, it could be hard to understand
  • unlike the rest of the app’s dependencies, the configuration itself was a concrete type (struct) instead of a protocol, meaning it was cumbersome to mock in tests and previews

New implementation

Phase 1

Macros are known to increase compilation time due to their dependency on the SwiftSyntax library. While this issue should be solved for macOS with the release of Swift 6.2 in Xcode 26, there are already workarounds that mitigate the issue.

The first attempt at simplifying the implementation was introducing the @FeatureConfigurationDescription macro to generate the members of the container struct. The user would only specify the types once and the macro would do the rest:

@FeatureConfigurationDescription(
    flags: [
        // ...
        IsProfileCreditEnabled.self,
    ],
    abTests: [
        // ...
        RateUsFormVisibility.self,
    ]
)
struct ProfileFeatureConfiguration: FeatureConfigurationDescribing {}

This would generate:

struct ProfileFeatureConfiguration: FeatureConfigurationDescribing {
    struct FeatureFlags {
        // ...
        let isProfileCreditEnabled = IsProfileCreditEnabled()
    }

    struct ABTests {
        // ...
        let rateUsFormVisibility = RateUsFormVisibility()
    }

    let featureFlags = FeatureFlags()

    let abTests = ABTests()

    let accessor: any FeatureConfigurationAccessing
}

This was an improvement, but it didn’t address the core design issues..

Phase 2

One of the obstacles in simplifying the system was the separation into multiple files. We decided to nest the feature definitions in the container itself. At the same time, the container should be turned into a protocol to align it with the rest of the app’s dependencies. Since protocol requirement declarations don’t have a way to specify any associated data, we added two more macros, @FeatureFlag and @ABTest, which we can use to add more information to the variables. They don’t expand to anything by themselves but instead modify the behavior of @FeatureConfigurationDescription.

@FeatureConfigurationDescription
protocol ProfileFeatureConfiguration {

    // ...

    @FeatureFlag("is_profile_credit_enabled", defaultValue: false)
    var isProfileCreditEnabled: Bool { get }

    // ...

    @ABTest("ab_profile_feedback_item")
    var rateUsFormVisibility: RateUsFormVisibilityValue { get }
}

Having different macros for feature flags and A/B tests allows us to quickly scan the file for either type. Since protocols don’t currently allow nested type declarations, the macro will instead generate a struct that will conform to the protocol. The above now generates:

struct ProfileFeatureConfigurationImplementation: ProfileFeatureConfiguration {

    // ...

    struct IsProfileCreditEnabled: FeatureFlag, RemotelyConfigurable {
        static let remoteConfigurationKey = "is_profile_credit_enabled"
        static let defaultValue = false
    }
    var isProfileCreditEnabled: Bool {
        accessor.value(of: IsProfileCreditEnabled.self)
    }

    // ...

    struct RateUsFormVisibility: ABTest, RemotelyConfigurable {
        static let remoteConfigurationKey = "ab_profile_feedback_item"
        typealias Value = RateUsFormVisibilityValue
    }
    var rateUsFormVisibility: RateUsFormVisibilityValue {
        accessor.value(of: RateUsFormVisibility.self)
    }

    let accessor: any FeatureConfigurationAccessing
}

A/B tests and custom values

The difference between feature flags and A/B tests is that the former can only have two values, true or false, while the latter can have arbitrary values. Most A/B tests in our app conform to the simplified OnOffABTest protocol that has three possible standardized values:

protocol OnOffABTest: ABTest where Value == OnOffABTestValue {}

enum OnOffABTestValue: String, ABTestValue {
    case none = "None"
    case off = "Off"
    case on = "On"

    static let enabledValues: Set<Self> = [.on]
}

For demonstration purposes, this post uses rateUsFormVisibility, which has custom values. Eagle-eyed readers might have noticed the substitution of RateUsFormVisibility.Value for RateUsFormVisibilityValue earlier. This is a necessary change because it is currently impossible to declare nested types inside protocols. Instead, we have to write the value outside of the configuration:

enum RateUsFormVisibilityValue: ABTestValue {
    case none = "ab_profile_feedback_item_none"
    case hidden = "ab_profile_feedback_item_hidden"
    case allUsers = "ab_profile_feedback_item_all_users"
    case signedUsers = "ab_profile_feedback_item_signed_users"

    static let enabledValues: Set<Self> = [.allUsers, .signedUsers]
}

This declaration can also be simplified by using a macro:

@CustomABTestValue(
    none: "ab_profile_feedback_item_none",
    disabled: "ab_profile_feedback_item_hidden",
    enabled: "ab_profile_feedback_item_all_users", "ab_profile_feedback_item_signed_users"
)
enum RateUsFormVisibilityValue: ABTestValue {}

Since an enum with no cases cannot declare a raw value, we have the macro add the requirements of RawRepresentable. The above generates:

enum RateUsFormVisibilityValue: ABTestValue {
    case none
    case hidden
    case allUsers
    case signedUsers

    static let enabledValues: Set<RateUsFormVisibilityValue> = [.allUsers, .signedUsers]

    init?(rawValue: String) {
        switch rawValue {
            case "ab_profile_feedback_item_none": self = .none
            case "ab_profile_feedback_item_hidden": self = .hidden
            case "ab_profile_feedback_item_all_users": self = .allUsers
            case "ab_profile_feedback_item_signed_users": self = .signedUsers
            default: return nil
        }
    }

    var rawValue: String {
        switch self {
            case .none: "ab_profile_feedback_item_none"
            case .hidden: "ab_profile_feedback_item_hidden"
            case .allUsers: "ab_profile_feedback_item_all_users"
            case .signedUsers: "ab_profile_feedback_item_signed_users"
        }
    }
}

Mocks

In addition to the implementation struct, the @FeatureConfigurationDescription macro can be enhanced to also generate a mock, which until now had to be written by hand. Since the macro has access to the default values, the mock can also use them:

struct FakeProfileFeatureConfiguration: ProfileFeatureConfiguration {
    // ...
    let isProfileCreditEnabled: Bool
    // ...
    let rateUsFormVisibility: RateUsFormVisibilityValue
}

// The factory method uses default argument values provided by the protocol members
extension ProfileFeatureConfiguration where Self == FakeProfileFeatureConfiguration {
    static func mock(
        // ...
        isProfileCreditEnabled: Bool = false,
        // ...
        rateUsFormVisibility: RateUsFormVisibilityValue = .none,
    ) -> Self {
        Self(
            // ...
            isProfileCreditEnabled: isProfileCreditEnabled,
            // ...
            rateUsFormVisibility: rateUsFormVisibility,
        )
    }
    static var empty: Self {
        mock()
    }
}

The final product

Putting it all together, we declare the following:

@FeatureConfigurationDescription
protocol ProfileFeatureConfiguration {

    // ...

    @FeatureFlag("is_profile_credit_enabled", defaultValue: false)
    var isProfileCreditEnabled: Bool { get }

    // ...

    @ABTest("ab_profile_feedback_item")
    var rateUsFormVisibility: RateUsFormVisibilityValue { get }
}

@CustomABTestValue(
    none: "ab_profile_feedback_item_none",
    disabled: "ab_profile_feedback_item_hidden",
    enabled: "ab_profile_feedback_item_all_users", "ab_profile_feedback_item_signed_users"
)
enum RateUsFormVisibilityValue: ABTestValue {}

and the macro generates this for us:

struct ProfileFeatureConfigurationImplementation: ProfileFeatureConfiguration {

    // ...

    struct IsProfileCreditEnabled: FeatureFlag, RemotelyConfigurable {
        static let remoteConfigurationKey = "is_profile_credit_enabled"
        static let defaultValue = false
    }
    var isProfileCreditEnabled: Bool {
        accessor.value(of: IsProfileCreditEnabled.self)
    }

    // ...

    struct RateUsFormVisibility: ABTest, RemotelyConfigurable {
        static let remoteConfigurationKey = "ab_profile_feedback_item"
        typealias Value = RateUsFormVisibilityValue
    }
    var rateUsFormVisibility: RateUsFormVisibilityValue {
        accessor.value(of: RateUsFormVisibility.self)
    }

    let accessor: any FeatureConfigurationAccessing
}

enum RateUsFormVisibilityValue: ABTestValue {
    case none
    case hidden
    case allUsers
    case signedUsers

    static let enabledValues: Set<RateUsFormVisibilityValue> = [.allUsers, .signedUsers]

    init?(rawValue: String) {
        switch rawValue {
            case "ab_profile_feedback_item_none": self = .none
            case "ab_profile_feedback_item_hidden": self = .hidden
            case "ab_profile_feedback_item_all_users": self = .allUsers
            case "ab_profile_feedback_item_signed_users": self = .signedUsers
            default: return nil
        }
    }

    var rawValue: String {
        switch self {
            case .none: "ab_profile_feedback_item_none"
            case .hidden: "ab_profile_feedback_item_hidden"
            case .allUsers: "ab_profile_feedback_item_all_users"
            case .signedUsers: "ab_profile_feedback_item_signed_users"
        }
    }
}

struct FakeProfileFeatureConfiguration: ProfileFeatureConfiguration {
    // ...
    let isProfileCreditEnabled: Bool
    // ...
    let rateUsFormVisibility: RateUsFormVisibilityValue
}

extension ProfileFeatureConfiguration where Self == FakeProfileFeatureConfiguration {
    static func mock(
        // ...
        isProfileCreditEnabled: Bool = false,
        // ...
        rateUsFormVisibility: RateUsFormVisibilityValue = .none,
    ) -> Self {
        Self(
            // ...
            isProfileCreditEnabled: isProfileCreditEnabled,
            // ...
            rateUsFormVisibility: rateUsFormVisibility,
        )
    }
    static var empty: Self {
        mock()
    }
}

The simple querying of config values is preserved, but now the members are generated by the macro instead of using @dynamicMemberLookup:

if configuration.isProfileCreditEnabled {
    // do something if the flag is enabled
}

The automatically generated mock can be accessed using the factory method, customizing only what is needed and using default values otherwise:

ProfileViewModel(
    // ...
    configuration: .mock(isProfileCreditEnabled: true),
)

Benefits

The updated system has multiple advantages:

  • Centralized declarations — impossible to forget registration
  • Concise syntax — easy to add or change values
  • Automatic mocks — less boilerplate, consistent defaults between production and tests
  • Extensibility — new features can be added by adjusting the macro

Implementation

This post doesn’t include the macro implementation code. It’s around 600 lines of SwiftSyntax code, most of which were generated by AI. Whether or not you like AI, it excels at producing SwiftSyntax boilerplate with minimal guidance.

Conclusion

Excluding the macro implementations themselves, switching to this new approach saved us hundreds of lines of code and made feature configuration updates far more convenient. This article only scratches the surface—for example, the debug menu integration (for runtime overrides) and dependency registration hooks are also generated. The macro system’s potential is extensive: once a new behavior is added, it becomes instantly reusable across the app.