Skip to content

Scoped Feature Flags in Swift using @TaskLocal

· 5 min read
swift

In our iOS project, testing feature-flagged code meant swapping a mock provider into a global, running the test, and remembering to revert afterward. Every test mutated shared state, and a forgotten cleanup leaked flag overrides into other tests.

If you’ve used swift-dependencies by Point-Free, you’ve seen a better pattern: withDependencies overrides a dependency for a specific scope and reverts automatically. I wanted the same ergonomics for our feature flags:

let sut = withExperiment(.darkMode, enabled: true) {
    SettingsView()
}
// sut.isDarkModeEnabled is true for the object's lifetime, not just inside the block

There are two pieces to making that work: a snapshot taken when the property wrapper initializes, and a @TaskLocal override that decides what gets snapshotted.

The Starting Point

Our original @Experiment property wrapper reads a value from a static flag provider:

@propertyWrapper
struct Experiment {
    static var provider = RealFeatureFlagProvider()

    let featureFlag: FeatureFlag

    init(_ featureFlag: FeatureFlag) {
        self.featureFlag = featureFlag
    }

    var wrappedValue: Bool {
        Experiment.provider.isEnabled(featureFlag)
    }
}

Usage in production is clean:

struct SettingsView: View {
    @Experiment(.darkMode) var isDarkModeEnabled

    var body: some View {
        if isDarkModeEnabled {
            DarkModeWrapper()
        }
    }
}

Testing is where it falls apart: every override goes through the shared static, so each test has to swap in a mock provider and remember to restore it afterward. One forgotten cleanup and the override leaks into the next test.

Snapshot at Initialization

The first piece is capturing the provider when the property wrapper is initialized, not when it’s read. The object then carries its own provider reference for its entire lifetime, regardless of what happens to the global state afterward.

let snapshotProvider: FeatureFlagProvider

init(_ featureFlag: FeatureFlag) {
    self.featureFlag = featureFlag
    snapshotProvider = Self.resolvedProvider  // snapshot here
}

var wrappedValue: Bool {
    snapshotProvider.isEnabled(featureFlag)  // read from snapshot, not global
}

That leaves the second piece: setting the right provider to be captured during initialization.

@TaskLocal

Swift’s @TaskLocal provides scoped, concurrency-safe value propagation. Unlike mutating a plain static, a @TaskLocal value is scoped to the current task, so parallel tests with different overrides don’t interfere with each other. It can be temporarily overridden for the duration of a closure using withValue, and it automatically reverts when the closure exits.

One requirement that’s easy to miss: a @TaskLocal value must be Sendable, since it can cross task boundaries. That means the protocol has to refine it — protocol FeatureFlagProvider: Sendable — and every conforming provider must be safe to share. EphemeralFeatureFlagProvider below qualifies because all of its state is immutable.

Our computed resolvedProvider checks the @TaskLocal override first; if it’s nil, we fall back to the defaultProvider. Here’s the full implementation:

@propertyWrapper
struct Experiment {
    @TaskLocal
    static var overrideProvider: FeatureFlagProvider? = nil

    private static let defaultProvider = RealFeatureFlagProvider()

    /// TaskLocal override if present, otherwise use default
    static var resolvedProvider: FeatureFlagProvider {
        overrideProvider ?? defaultProvider
    }

    private let snapshotProvider: FeatureFlagProvider
    private let featureFlag: FeatureFlag

    init(_ featureFlag: FeatureFlag) {
        self.featureFlag = featureFlag
        snapshotProvider = Self.resolvedProvider
    }

    var wrappedValue: Bool {
        snapshotProvider.isEnabled(featureFlag)
    }
}

withExperiment()

@discardableResult
func withExperiment<R>(
    _ featureFlag: FeatureFlag,
    enabled: Bool,
    operation: () -> R
) -> R {
    let override = EphemeralFeatureFlagProvider(
        base: Experiment.resolvedProvider,
        overrides: [featureFlag: enabled])
    return Experiment.$overrideProvider.withValue(override) {
        operation()
    }
}

TaskLocal.withValue pushes the override onto the task-local stack, runs the closure, then pops it automatically. Any @Experiment initialized inside captures the override.

The EphemeralFeatureFlagProvider layers overrides on top of whatever provider was previously active; non-overridden flags resolve to the base:

final class EphemeralFeatureFlagProvider: FeatureFlagProvider {
    private let base: FeatureFlagProvider
    private let overrides: [FeatureFlag: Bool]

    init(base: FeatureFlagProvider, overrides: [FeatureFlag: Bool]) {
        self.base = base
        self.overrides = overrides
    }

    func isEnabled(_ featureFlag: FeatureFlag) -> Bool {
        overrides[featureFlag] ?? base.isEnabled(featureFlag)
    }
}

In Action

Objects created inside the scope retain the override for their lifetime:

func test_darkModeEnabled() {
    let sut = withExperiment(.darkMode, enabled: true) {
        SettingsView()
    }

    print(sut.isDarkModeEnabled) // prints `true`
}

This gives us similar ergonomics to @Dependency from Point-Free, with minimal changes to the property wrapper and nothing beyond built-in Swift features.