Macro Feature Flag System

Posted Wednesday, November 15, 2023.

Introduction

We want to create a feature flag system that can be updated (likely from an API) and overriden locally for development, but also with minimal code needed for maintenance. Lets say you have some UI setup for handling overriding flags. We would like to create a system where we add the flag in one place an for free we get:

  1. An up to date list of flags (if you use an enum with CaseIterable you can get this for free, but only with an enum)
  2. UI for overriding the flag
  3. Publishers generated to subscribe to changes in the flag (from locally overriding or from the API updating)

To do these things, we can create a small library with a macro and a property wrapper to help us. Consuming this library will look like this:

import Features

@FeatureSet
enum Features {
@Feature("FlagOne") static var flagOne
@Feature("FlagTwo") static var flagTwo
}

And thats it! Behind the scenes you get all of this for free:

import Features
import SwiftUI

@FeatureSet
enum Features {
@Feature("FlagOne") static var flagOne
@Feature("FlagTwo") static var flagTwo

// Generated by @FeatureSet macro
static func update(with payload: [String: Bool]) {
flagOne.update(value: payload["FlagOne"])
flagTwo.update(value: payload["FlagTwo"])
}
}

// Generated by @FeatureSet macro
extension Features: CaseIterable {
static let allCases: [Flag] = [
flagOne,
flagTwo
]
}

// Built in from @Feature property wrapper
let enabled = Features.flagOne.enabled
let cancellable = Features.$flagOne.sink { _ in }
let view = FeaturesView(flags: Features.allCases)

Package Structure

Create a new swift package (swift package init --type library). The finished file structure of this package will look like:

.
├── Sources
│ ├── Features
│ │ ├── Feature.swift
│ │ ├── FeaturesView.swift
│ │ ├── Flag.swift
│ │ └── Macros.swift
│ └── FeaturesMacro
│ ├── FeatureSetMacro.swift
│ └── Macros.swift
├── Package.swift
└── Package.resolved

Package.swift

In our Package.swift we need to import CompilerPluginSupport to be able to use .macro targets. We also need to rely on the SwiftSyntax dependency to write our macro.

// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

import CompilerPluginSupport
import PackageDescription

let package = Package(
name: "features",
platforms: [
.iOS(.v15),
.macOS(.v12)
],
products: [
.library(name: "Features", targets: ["Features"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0"),
],
targets: [
.macro(
name: "FeaturesMacros",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
]
),
.target(
name: "Features",
dependencies: [
.target(name: "FeaturesMacros")
]
)
]
)

FeaturesMacros/Macros.swift

We are going to write and expose only 1 macro called FeatureSet. In order to expose our macro to the Features target, we need to expose it using @main and conforming our type to CompilerPlugin.

import SwiftCompilerPlugin
import SwiftSyntaxMacros

@main
struct FeaturesPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
FeatureSetMacro.self
]
}

FeaturesMacros/FeatureSetMacro.swift

Now we can start writing our FeatureSet macro. We will start with the type and a couple helpers.

import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public enum FeatureSetMacro {}

extension DeclModifierSyntax {
var isPublic: Bool {
switch name.tokenKind {
case .keyword(.public): true
default: false
}
}
}

extension DeclModifierSyntax {
var isStatic: Bool {
switch name.tokenKind {
case .keyword(.static): true
default: false
}
}
}

extension SyntaxStringInterpolation {
// It would be nice for SwiftSyntaxBuilder to provide this out-of-the-box.
mutating func appendInterpolation<Node: SyntaxProtocol>(_ node: Node?) {
if let node {
appendInterpolation(node)
}
}
}

Our macro is going to be both a member and extension type macro.

Member Macro Conformance

Because we want to generate a member (update(with:)) with this macro, we need to conform to MemberMacro. We look through all the members of the declaration we are attached to, and look for static members that are annotated @Feature(...). We need to get both the variable name and string value of the feature.

extension FeatureSetMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
let access = declaration.modifiers.first(where: \.isPublic)

let members = declaration
.memberBlock
.members
.compactMap { member -> (String, String)? in
guard
let variable = member.decl.as(VariableDeclSyntax.self),
let attribute = variable.attributes.first?.as(AttributeSyntax.self),
let attributeName = attribute.attributeName.as(IdentifierTypeSyntax.self),
attributeName.name.text == "Feature",
variable.modifiers.contains(where: \.isStatic),
let arguments = attribute.arguments?.as(LabeledExprListSyntax.self),
let argument = arguments.first?.as(LabeledExprSyntax.self),
let expression = argument.expression.as(StringLiteralExprSyntax.self),
let flagString = expression.segments.first?.as(StringSegmentSyntax.self)?.content.text,
let binding = variable.bindings.first?.as(PatternBindingSyntax.self),
let flagName = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text
else { return nil }
return (flagString, flagName)
}
.map { string, name in
#" \#(name).update(value: payload["\#(string)"])"#
}
.joined(separator: "\n")

return [
"""
\(access)static func update(with payload: [String: Bool]) {
\(raw: members)
}
"""
]
}
}

This will generate our update method and the output will look something like this:

static func update(with payload: [String: Bool]) {
flagOne.update(value: payload["FlagOne"])
flagTwo.update(value: payload["FlagTwo"])
...
}

It is handy because as we add/remove flags, we dont have to keep this update(with:) method up to date.

Extension Macro Conformance

We also want to conform to CaseIterable and provide the implementation for allCases. To do this we need to conform to ExtensionMacro. In here we only need to get the variable names of static members that are marked @Feature(...).

extension FeatureSetMacro: ExtensionMacro {
public static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingExtensionsOf type: some TypeSyntaxProtocol,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [ExtensionDeclSyntax] {
let access = declaration.modifiers.first(where: \.isPublic)

let members = declaration
.memberBlock
.members
.compactMap { member -> String? in
guard
let variable = member.decl.as(VariableDeclSyntax.self),
let attribute = variable.attributes.first?.as(AttributeSyntax.self),
let attributeName = attribute.attributeName.as(IdentifierTypeSyntax.self),
attributeName.name.text == "Feature",
variable.modifiers.contains(where: \.isStatic),
let binding = variable.bindings.first?.as(PatternBindingSyntax.self),
let flagName = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text
else { return nil }
return flagName
}
.map { name in
#" \#(name)"#
}
.joined(separator: ",\n")

let extensionDecl = ExtensionDeclSyntax(
extendedType: type,
inheritanceClause: .init(inheritedTypes: .init(arrayLiteral: .init(type: IdentifierTypeSyntax(name: "CaseIterable"))))
) {
"""
\(access)static let allCases: [Flag] = [
\(raw: members)
]
"""
}

return [extensionDecl]
}
}

This will generate the extension to our type and will look something like this:

extension MyTypeWithFeatureSetMacro: CaseIterable {
static let allCases: [Flag] = [
flagOne,
flagTwo,
...
]
}

Now we can move into our Features target and expose the macro from our library.

Features/Macros.swift

We conformed our macro type to both MemberMacro and ExtensionMacro so now we need to wire it up. We add @attached(member, names: named(update)).

@attached(member, names: named(update)) // 1
@attached(extension, conformances: CaseIterable, names: named(allCases)) // 2
public macro FeatureSet() = #externalMacro(module: "FeaturesMacros", type: "FeatureSetMacro") // 3

Explanation:

  1. Needed for member macro and notes that we will be creating a member named update (the static function to update the flags)
  2. Needed for extension macro and notes that we will be conforming to the protocol CaseIterable and creating a member named allCases (static requirement for CaseIterable protocol)
  3. Publicly expose the macro so it can be used with @FeatureSet and links it to our FeaturesMacro target.

Features/Flag.swift

The type of each feature flag will be a Flag. Because features can only be one type it is nice that we can use the propery wrapper to hold the type and dont have to declare it for each flag:

@FeatureSet
enum Features {
@Feature("MyFlag") static var myFlag
// vs
@Feature("MyFlag") static var myFlag: Flag // compiles but is redundant
}

This is the underlying type that holds the state for each flag and persists overriding to UserDefaults. Our member macro created a function update that in turn calls the update method on each flag that is in the feature set.

import Combine
import Foundation

public class Flag: ObservableObject, Identifiable {
let name: String
let key: String
var storage = false
@Published var overriden: Bool
@Published public private(set) var isEnabled: Bool

init(name: String) {
let key = "_override-\(name)"
let override = UserDefaults.standard.object(forKey: key) as? Bool
self.key = key
self.overriden = override != nil
self.isEnabled = override ?? false
self.name = name
}

func override(value: Bool) {
if value == storage {
overriden = false
UserDefaults.standard.removeObject(forKey: key)
} else {
overriden = true
UserDefaults.standard.setValue(value, forKey: key)
}
isEnabled = value
}

public func update(value: Bool?) {
guard let value else { return }
storage = value
reset()
}

func reset() {
isEnabled = storage
overriden = false
UserDefaults.standard.removeObject(forKey: key)
}

public var id: String { name }
}

We conform to Identifiable so we can use the array in a List or ForEach in SwiftUI (though this is not required, we could also do ForEach(flags, id: \.name)).

Features/Feature.swift

The property wrapper for Feature is quite simple. It passes the name string along to its wrappedValue of Flag but then also forwards the publisher out as its projected value. We marked the isEnabled property as public private(set) because we don't want it to be easy to update the value of the feature flag. It should only be done externally from the API or from the local overrides (we wouldn't want someone to accidentally be able to do Features.flagOne.isEnabled = false and turn it off in code)

import Combine

@propertyWrapper
public struct Feature {
public init(_ name: String) {
self.wrappedValue = Flag(name: name)
}

public var wrappedValue: Flag

public var projectedValue: AnyPublisher<Bool, Never> {
wrappedValue.$isEnabled.eraseToAnyPublisher()
}
}

Features/FeaturesView.swift

This is a pretty quick and dirty implementation of UI for allowing overriding flags. It takes in an array of flags (which we get for free from our @FeatureSet macro) and displays them in a list which is searchable by name. From here you can toggle flags on and off and see which ones are overriden.

import SwiftUI

@MainActor
final class FeaturesViewModel: ObservableObject {
let flags: [Flag]
@Published var search = ""
@Published var filtered: [Flag]

init(flags: [Flag]) {
self.flags = flags
self.filtered = flags

$search
.removeDuplicates()
.delay(for: 0.1, scheduler: RunLoop.main)
.map { query in
if query.isEmpty {
return flags
} else {
let search = query.lowercased()
return flags.filter { $0.name.lowercased().contains(search) }
}
}
.assign(to: &$filtered)
}
}

public struct FeaturesView: View {
@StateObject var viewModel: FeaturesViewModel

public init(flags: [Flag]) {
self._viewModel = .init(wrappedValue: .init(flags: flags))
}

public var body: some View {
List(viewModel.filtered) { flag in
FlagView(flag: flag)
}
.searchable(text: $viewModel.search)
.navigationTitle("Search Features")
}
}

struct FlagView: View {
@ObservedObject var flag: Flag

init(flag: Flag) {
self.flag = flag
}

var binding: Binding<Bool> {
.init {
flag.isEnabled
} set: { newValue in
flag.override(value: newValue)
}
}

var body: some View {
HStack {
Text(flag.name)

if flag.overriden {
Text("Overridden")
.font(.caption2)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background {
Capsule()
.fill(.red)
}
}

Spacer()

if flag.overriden {
Button {
flag.reset()
} label: {
Image(systemName: "gobackward")
}
}

Toggle("Enabled", isOn: binding.animation())
.labelsHidden()
}
}
}

iOS App Usage

Now in our iOS app we can use this library with the macro and property wrapper we created earlier. Start by importing the Features library and adding it as a dependency to your target.

Features.swift

Here we create our feature set with some custom flags.

import Features

@FeatureSet
enum Features {
@Feature("BlueText") static var blueText
@Feature("RedBackground") static var redBackground
}

ContentView.swift

Updating our ContentView we can showcase how our new feature flag system works. I will explain a couple of things below:

import Combine
import Features
import SwiftUI

extension Color {
static let systemBackground = Color(uiColor: .systemBackground)
}

@MainActor final class VM: ObservableObject {
@Published var color: Color = .systemBackground

init() {
// 1
Features
.$redBackground
.receive(on: RunLoop.main)
.map { $0 ? Color.red : .systemBackground }
.assign(to: &$color)
}
}

struct ContentView: View {
@State private var show = false
@State private var count = 0
@StateObject private var vm = VM()

var body: some View {
NavigationStack {
ZStack {
vm.color.ignoresSafeArea()

VStack {
// 2
Text("Hello, world! \(count)")
.background(Features.blueText.isEnabled ? Color.blue : Color.clear)

Button("Refresh") { count += 1 }

// 3
Button("Simulate FF Sync") {
Features.update(with: [
"BlueText": Bool.random(),
"RedBackground": Bool.random()
])
}
}
}
.navigationTitle("Feature Test")
.toolbar {
Button("Features") {
show = true
}
}
}
.sheet(isPresented: $show) {
FeaturesSheet()
}
}
}

struct FeaturesSheet: View {
@Environment(\.dismiss) private var dismiss

var body: some View {
NavigationStack {
FeaturesView(flags: Features.allCases)
.navigationTitle("Features")
.toolbar {
Button("Close", action: dismiss.callAsFunction)
}
}
}
}
  1. Here is an example of how we can subscribe to changes. You can see when the feature sheet is presented, changing the background toggle will update the main background color automatically

  2. Here we are using an imperitive check that is not subscribed to changes, so it won't re-evaluate unless its view does. This is why there is a refresh button that changes the count, which in turn invalides the Text and causes its .background(...) to re-execute and again evaluate the enabled status of the flag.

  3. This randomly sets the flags, "simulating" an update that an API would probably do:

let payload: [String: Bool] = try await featureService.fetch()
Features.update(with: payload) // any subscribers to any of the flags will be updated!

Conclusion

This was a pretty throw together example, but I think it does a good job of showcasing a simple macro and propertywrapper and how they can work together to reduce a lot of the code you write and repetition.

As always, if there is anything glaringly wrong or a better way to do something, let me know!


Tagged With: