Context-scoped effect handling for Swift concurrency — describe side effects as values, perform them with typed throws, and swap the handler per scope for deterministic tests.
- Typed throws end-to-end —
Effect.perform(_:)returnsE.Valueor throws exactlyE.Failure; infallible effects (Failure == Never) perform withouttry. - Context-scoped handlers — handlers resolve through dependency-context keys, so a scope override changes behavior without touching call sites.
- Test doubles as a separate product —
Effect.Test.Handler,Effect.Test.Spy, andEffect.Test.Recorderlive in theEffects Testingproduct, keeping test-only surface out of production builds. - Interceptable built-ins —
Effect.ExitandEffect.Yieldturn process termination and scheduler yields into effects a test can observe instead of suffer.
An effect is a value describing what should happen. The handler deciding how is looked up from the current context, so the same call site is non-deterministic in production and fixed in a test scope:
import Effects
import Effects_Testing
import Dependency_Primitives
struct RollDice: Effect.`Protocol`, EffectWithHandler {
typealias Value = Int
typealias Failure = Never
typealias HandlerKey = Key
struct Key: Dependency.Key {
typealias Value = Effect.Test.Handler<RollDice>
static var liveValue: Value { .init(returning: { _ in Int.random(in: 1...6) }) }
static var testValue: Value { liveValue }
}
}
// Override the handler for one scope; the perform call site is unchanged.
let roll = await Effect.Context.with({ handlers in
handlers[RollDice.Key.self] = .init(returning: 4)
}) {
await Effect.perform(RollDice())
}
// roll == 4Fallible effects declare a concrete Failure type, and Effect.perform throws exactly that type — no any Error to downcast at the catch site.
No versions are tagged yet; pin to main:
dependencies: [
.package(url: "https://github.com/swift-foundations/swift-effects.git", branch: "main")
].target(
name: "YourTarget",
dependencies: [
.product(name: "Effects", package: "swift-effects")
]
),
.testTarget(
name: "YourTargetTests",
dependencies: [
.product(name: "Effects Testing", package: "swift-effects")
]
)- Swift 6.3+
- macOS 26+, iOS 26+, tvOS 26+, watchOS 26+, visionOS 26+
| Product | Module | When to import |
|---|---|---|
Effects |
Effects |
Declaring and performing effects. Re-exports the effect and dependency primitives it builds on, so one import covers Effect.perform, EffectWithHandler, and Effect.Context. |
Effects Built-in |
Effects_Built_in |
Ready-made Effect.Exit and Effect.Yield. Re-exports Effects. |
Effects Testing |
Effects_Testing |
Test doubles for asserting on performed effects. Test targets only. |
The built-in catalog is deliberately small at this stage:
| Effect | Live behavior | Under test |
|---|---|---|
Effect.Exit |
Terminates the process (Value == Never). The default live handler is a cross-platform fatalError fallback; platform packages supply handlers that call the real exit function. |
The test handler suspends instead of terminating, so a test can observe the exit request and cancel or time out. |
Effect.Yield |
Delegates to Task.yield(). |
A custom Effect.Yield.Handler can count or suppress yields for deterministic scheduling tests. |
import Effects_Built_in
await Effect.Exit.perform(code: 1) // never returns in production
await Effect.Yield.perform()The Effects Testing product provides three test doubles:
| Type | Purpose |
|---|---|
Effect.Test.Handler<E> |
Returns a canned value (init(returning:)), a canned error (init(throwing:)), or runs a closure per effect. |
Effect.Test.Spy<E> |
Wraps a handler and records every invocation — effect value, timestamp, and outcome — for callCount / invocations assertions. |
Effect.Test.Recorder |
Type-erased recorder collecting invocations of different effect types into one timeline. |
Discussion thread will be created at first public release.
Apache 2.0. See LICENSE.