Scoped Feature Flags in Swift using @TaskLocal — Aaron Orozco Skip to content

Scoped Feature Flags in Swift using @TaskLocal

· 5 min read

If you’ve used swift-dependencies by Point-Free, you’re probably familiar with the withDependencies function as a way to override dependencies for a specific scope, it’s particularly useful when writing tests.

Using that as inspiration, I wanted to add the same ergonomics for feature flags in our app.

The Starting Point

Our original @Experiment property wrapper is simple, it 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()
        }
    }
}

This works but testing is clunky — you’d need to swap in a mock provider, run your test, then remember to revert the global state afterward. Every test mutates shared state, and forgetting to clean up means flag overrides leak into other tests.

The Goal

Borrowing from withDependencies, here’s the API we want:

let sut = withExperiment(.darkMode, enabled: true) {
    SettingsView()
}
// sut.isDarkModeEnabled should be true in current scope, not just inside the block

The object created inside the scope should carry the override for its entire lifetime, even after the closure exits.

Snapshot at Initialization

The key is to capture the provider when the property wrapper is initialized, not when it’s read. This way, the object 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
}

Next we need to solve: how do we set 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 will not 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.

Our computed resolvedProvider checks the @TaskLocal override first, if nil then we simply resolve to the defaultProvider.

Here’s the full implementation:

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

    private static let defaultProvider = RealFeatureFlagProvider()

    /// TaskLocal override if present, otherwise use default
    private 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: resolvedProvider,
        overrides: [featureFlag: enabled])
    return $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 case:

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! The code is readable, effective, tightly-scoped and leans into built-in Swift features with minimal changes to the actual property wrapper.