Swift Effect
Algebraic Effects and Effect Handlers for Swift
Swift Effect is an architecture-agnostic effect system that makes side effects (such as I/O, networking, randomness, concurrency) controllable, composable, and testable without forcing structural changes to your application code. With just two lightweight abstractions, Effects and Effect Handlers, it enables natural composition of behaviours while keeping application code linear, procedural, and easy to reason about. In tests, the same mechanism powers mock-less testing of behaviours: observable effects can be intercepted, suspended, and resumed with just-in-time test data, without invasive scaffolding or test-only abstractions in application code, commonly required by traditional DI libraries and archit…
Swift Effect
Algebraic Effects and Effect Handlers for Swift
Swift Effect is an architecture-agnostic effect system that makes side effects (such as I/O, networking, randomness, concurrency) controllable, composable, and testable without forcing structural changes to your application code. With just two lightweight abstractions, Effects and Effect Handlers, it enables natural composition of behaviours while keeping application code linear, procedural, and easy to reason about. In tests, the same mechanism powers mock-less testing of behaviours: observable effects can be intercepted, suspended, and resumed with just-in-time test data, without invasive scaffolding or test-only abstractions in application code, commonly required by traditional DI libraries and architectural frameworks.
🔗 Jump to:
-
✨ Features
-
📖 Examples
-
❓FAQ
-
1. How is programming with Effects different from Dependency Injection?
-
2. Aren’t effects just global functions? Aren’t globals bad?
✨ Features
- Minimal but General: Effects and Effect Handler form a minimal, operation-level abstraction (often representing an atomic operation such as
print) that can be freely composed with other effectful operations to build arbitrarily complex behaviours. This contrasts with traditional DI libraries that build on object- and type-level abstractions (for example, aConsoleService), which are more prone to leaking to application code, and are generally harder to compose due to their bespoke nature. - Composable: Effect Handlers can be nested in the same way as
do–try–catchexception handlers, placed anywhere in the stack hierarchy, enabling natural and intuitive composition of behaviours. This allows to separate application logic from specific behaviours, making programs modular, extensible, and portable. - Modular: Effects and Effect Handlers are just normal functions. This allows application code that performs Effects to remain completely decoupled from Effect Handlers that provide their behavior. Effects can be defined in one module and handled in another, and multiple Effect Handlers for the same Effect can be supplied by different modules as needed.
- Testable: The library provides a
TestHandler, a special Effect Handler that can intercept, suspend, inspect, and resume any Effects performed by the system under test. This enables a powerful testing style in which application code can be executed step by step, allowing tests to assert and interpret observable behaviour and state without ahead-of-time mocking, much like a human tester would by running the program and manually inputting data as it is needed. - Deterministic Concurrency: The library enables deterministic testing of Swift Concurrency primitives (such as Tasks, Task Groups (WIP), and Async Streams) by modelling them as controllable effects in their own right.
- Research-based: The library is informed by extensive theory and practice around computational effects. Swift Effect’s design is primarily inspired by the established effect systems in OCaml and Koka.
📖 Examples
Defining Effects
Let’s start with a simple echo CLI program that prints back each input line and exits when nil is entered:
func echo() {
while let line = readLine() { // ⚠️ I/O side effect
print(line) // ⚠️ I/O side effect
}
}
echo()
> Hello
Hello
> Good Bye
Good Bye
>
exit
This deceptively simple program performs two unmanaged I/O side effects,readLine and print, making it practically impossible to test or extend with custom behaviours.
Let’s turn readLine and print operations into controllable Effects:
enum Console { // Namespace
@Effect
static func readLine() -> String? {
Swift.readLine()
}
@Effect
static func print(_ line: String) {
Swift.print(line)
}
}
The @Effect macro exposes each operation to the effect system, and generates their corresponding Effect Handlers, which we’ll see in action shortly.
First, let’s update echo to use our new effects:
func echo() {
while let line = Console.readLine() {
Console.print(line)
}
}
Notice that we didn’t have to change the structure, control flow, or the interface of echo. It remains linear and procedural (in stark contrast to other effect systems that require restrucuring programs into unidirectional data flow or use monadic chaining).
If we run echo again, it will work the exact same way as before. Console.readLine and Console.print operations will be handled by the global Effect Handlers, which simply call the implementations of the two static functions we defined above. This on itself is unremarkable.
But we can now extend echo with custom behaviours by running it with custom effect handlers.
We do this by using the with-handle-perform effect handling block, which mirrors the semantics of do-try-catch exception handling block, so you can apply the same intuition here:
func main() {
with {
Print { line in
let uppercased = line.uppercased()
Swift.print(uppercased)
}
} perform: {
echo()
}
}
Now, each response is uppercased, without changing the original implementation of echo:
main()
> "Hello"
"HELLO"
> "Good Bye"
"GOOD BYE"
>
exit
Print effect handler was generated by the @Effect macro. Effect handlers take the capitalized names of corresponding effects.
When we run echo() and it performs Console.print effect, the first Print effect handler in the call stack will catch and handle this effect.
Crucially, echo doesn’t know or care about how Console.readLine and Console.print effects are handled. It focuses on the application logic built on top of these two abstract operations.
(Note: a set of abstract operations and the rules by which they work is known as Algebra, hence the term Algebraic Effects).
Effect composition
Effect handlers are just normal functions, and they can perform effects too, including the ones they handle. Let’s look at the uppercased Print effect handler again:
Print { line in
let uppercased = line.uppercased()
Swift.print(uppercased)
}
It’s problematic because it’s over-specified to use Swift.print implementation, while its only purpose is to just uppercase outputs.
We can make this handler more reusable and composable like this:
Print { line in
let uppercased = line.uppercased()
Console.print(uppercased) // yield print effect to the next handler
}
Instead of hard-coding Swift.print implementation, Print just uppercases outputs and then performs the abstract Console.print effect itself! Effect handlers follow the same rules as any other function, and all effects performed by effect handlers are caught and handled by the next corresponding handler in the call stack. Yielding effects from one handler to the next one works the same way as rethrowing errors from one do-try-catch exception handler to the next one.
This design gives rise to natural and intuitive composition of behaviour.
Let’s add logging to our echo program:
// main.app
import Module
func main() {
var log: [String] = []
with {
Print { line in
log.append(line)
Console.print(line) // yields to the next handler (global)
}
} perform: {
module()
}
}
// Module.framework
func module() {
with {
Print { line in
let uppedcased = line.uppedcased()
Console.print(uppedcased) // yields to the next handler (main)
}
} perform: {
echo()
}
}
main()
> Hello
HELLO // log: ["HELLO"]
> Good Bye
GOOD BYE // log: ["HELLO", "GOOD BYE"]
>
exit
with-handler-perform blocks are result builders, and can install multiple effect handlers at a time, and include control flow statements:
func main() {
var log: [String] = []
with {
if enableLogging {
Print { line in
log.append(line)
Console.print(line)
}
}
ReadLine {
Console.readLine().uppercased()
}
} perform: {
echo()
}
}
Testing
Finally, let’s put our echo program to test.
Swift Effect provides a special withTestHandler effect handler that can catch all effects produces by the program under test.
func echo() {
while let line = Console.readLine() {
Console.print(line)
}
}
@Test
func test() async throws {
try await withTestHandler {
echo() // 1
} test: { effect in
try await effect.expect(Console.ReadLine.self) { // 2
return "Hello" // 3
}
try await effect.expect(Console.Print.self) { line in // 4
#expect(line == "Hello") // 5
}
try await effect.expect(Console.ReadLine.self) {
return "Good Bye"
}
try await effect.expect(Console.Print.self) { line in
#expect(line == "Good Bye")
}
try await effect.expect(Console.ReadLine.self) {
return nil // exit // 6
}
}
}
Let’s break down the sequence of events:
echo()is launched insidewithTestHandlercontext.- The
testblock is immediately suspended on the first line, awaiting the first effect produced byecho, which is expected to beConsole.readLine. WhenechoperformsConsole.readLine(), execution ofechois suspended until a value is supplied by an effect handler. This effect is intercepted by thetesthandler, which attempts to match it against theeffect.expect(Console.ReadLine.self)expectation. If an unexpected effect is produces, the test fails. - Since the produced effect matches the expectation, the test resumes into the trailing closure, which acts as a just-in-time
ReadLineeffect handler. In this case, the handler simply returns the"Hello"line, allowingechoto resume execution and to echo"Hello"back. - The test then proceeds to
await effect.expect(Console.Print.self)and is suspended again, waiting for the next effect to be produced byecho. - When
echoperformsConsole.print("Hello"), execution ofechois again suspended while awaiting theprinteffect to be handled. The test intercepts the effect and attempts to match it against theawait effect.expect(Console.Print.self)expectation. Upon a successful match, the test resumes into the just-in-time trailing closure effect handler. In this case, the handler asserts#expect(line == "Hello")and returns, allowingechoto resume execution and proceed to the nextreadLineloop iteration. - This ping-pong exchange between the program and the test continues until a
ReadLinehandler returnsnil, causing the program to exit and the test to conclude.
The library also provides syntactic sugar for ergonomic effect matching using key paths, as well as return-only handling for effects that have no input arguments:
try await effect.expect(\.Console.readLine, return: "Hello")
try await effect.expect(\.Console.print) { #expect($0 == "Hello") }
Effect composition in Tests
What happens if we compose withTestHandler with another effect handler inside? , ,
@Test
func test() async throws {
try await withTestHandler { // outer handler
with { // inner handler
Print { _ in } // no-op print
} perform: {
echo()
}
} test: { effect in
try await effect.expect(\.Console.readLine, return: "Hello")
try await effect.expect(\.Console.readLine, return: "Good Bye")
try await effect.expect(\.Console.readLine, return: nil)
}
}
The same rule applies here: the first available effect handler in the call stack intercepts the effect. In this case, when echo performs Console.readLine(), the first available handler for readLine is the TestHandler, which catches the effect, matches it against the expectation, and returns "Hello".
But when echo performs Console.print, the first handler for this effect is the inner one providing the no-op Print behaviour. This handler does not yield the print effect to the next handler, so it effectively discharges the effect and it never reaches the TestHandler. As a result, the effect is completely invisible to TestHandler. In this sense, the inner handler acts as a filter for print effects.
Opt-in testing pattern with inner handlers
Consider this inner Print handler that discharges all print effects but yields to the next handler when "Hello" is printed:
@Test
func test() async throws {
try await withTestHandler { // outer handler
with { // inner handler
Print {
if $0 == "Hello" {
Console.print(line) // opt-in to testing "Hello" by yielding to the test handler
}
}
} perform: {
echo()
}
} test: { effect in
try await effect.expect(\.Console.readLine, return: "Hello")
try await effect.expect(\.Console.print) { #expect($0 == "Hello") } // opt-in
try await effect.expect(\.Console.readLine, return: "Hi")
try await effect.expect(\.Console.readLine, return: "Good Bye")
try await effect.expect(\.Console.readLine, return: nil)
}
}
In this test, we only care about testing "Hello" prints and ignore everything else. This pattern is useful when a program produces many irrelevant effects, for example, a large number of Logging.log(.debug, ...) effects. In such cases, we can filter out all log effects except those with LogLevel.error.
Opt-out testing pattern with outer handlers
Let’s reverse this composition by making Print wrap withTestHandler instead:
@Test
func test() async throws {
with { // outer handler
Print { _ in } // no-op stub
} perform: {
try await withTestHandler { // inner handler
echo()
} test: { effect in
try await effect.expect(\.Console.readLine, return: "Hello")
try await effect.expect(\.Console.print) { #expect($0 == "Hello") }
try await effect.expect(\.Console.readLine, return: "Hi")
try await effect.yield() // opt-out by yielding to the next handler (no-op Print)
try await effect.expect(\.Console.readLine, return: "Good Bye")
try await effect.yield() // opt-out by yielding to the next handler (no-op Print)
try await effect.expect(\.Console.readLine, return: nil)
}
}
}
In this arrangement, all effects are first caught by the TestHandler, but it can choose yield some effects to the next handler by calling try await effect.yield(). The outer handler can be a traditional mock that provides some "default" and reusable test behaviour. As you can see, the Minimal but General design principle of Swift Effect allows us to reproduce traditional workflows like DI+Test Doubles when we need them.
Async Streams and Effects
Swift Effect naturally fits asynchronous programming and provides a strong model for treating AsyncStream and AsyncSequence as controllable effects in test environment.
Let’s define an Effect that returns an async stream of random numbers, one number per second:
enum Random {
@Effect
static func numbers(in range: ClosedRange<Int>) -> any AsyncSequence<Int, any Error> {
AsyncThrowingStream {
try await Task.sleep(for: .seconds(1))
return Int.random(in: range)
}
}
}
Let’s create a View and View Model that consume this stream and update observable state in response to new numbers:
struct RandomNumberView: View {
let viewModel: RandomNumberViewModel
var body: some View {
Text(viewModel.message)
.task {
await viewModel.getRandomNumbers()
}
}
}
@MainActor
@Observable
class RandomNumberViewModel {
private var previousNumber: Int?
var message: String = ""
func getRandomNumbers() async {
do {
for try await number in Random.numbers(in: 0...100) {
if previousNumber == number {
message = "It's the same number again, boooring!!"
} else if number.isMultiple(of: 2) {
message = "\(number) is a good number"
} else {
message = "\(number) is a bad number"
}
previousNumber = number
}
message = "Good bye!"
} catch {
message = "Bad error"
}
}
}
Again, this deceptively simple ViewModel can be awkward to test with traditional DI approaches, often requiring carefully crafted mocks to exercise different asynchronous sequences. With Swift Effect, testing AsyncStreams and AsyncSequences is straightforward.
Semantically, an Effect that produces an AsyncSequence of elements is equivalent to an AsyncSequence of effects that each produce a single element. This is exactly how such effects are handled and tested:
@MainActor
@Test
func test() async throws {
let viewModel = RandomNumberViewModel()
try await withTestHandler {
await viewModel.getRandomNumbers()
} test: { effect in
try await effect.expect(\.Random.numbers) { range in
#expect(range == (0...100))
return 1
}
await #expect(viewModel.message == "1 is a bad number")
try await effect.expect(\.Random.numbers) { _ in 2 }
await #expect(viewModel.message == "2 is a good number")
try await effect.expect(\.Random.numbers) { _ in 3 }
await #expect(viewModel.message == "3 is a bad number")
try await effect.expect(\.Random.numbers) { _ in 3 }
await #expect(viewModel.message == "It's the same number again, boooring!!")
try await effect.expect(\.Random.numbers) { _ in nil } //end of stream
await #expect(viewModel.message == "Good Bye!")
}
}
As easily, we can throw an error from the stream to exercise the corresponding view state:
@MainActor
@Test
func testError() async throws {
try await withTestHandler {
await viewModel.getRandomNumbers()
} test: { effect in
...
try await effect.expect(\.Random.numbers) { throw SomeError() }
await #expect(viewModel.message == "Bad error")
}
}
Deterministic testing of Unstructured Concurrency
Swift Effect provides a built-in Task.effect that acts as a drop-in replacement for Task. In production, this effect simply returns a normal Task, leaving runtime behaviour unchanged. In tests, however, it allows the TestHandler to take control of task scheduling in a fully deterministic way.
Consider this program:
func program() {
Console.print("Start")
Task(name: "Task 1") {
Console.print("Task 1")
}
Task(name: "Task 2") {
Console.print("Task 2")
}
Console.print("Exit")
}
If we run program() multiple times, the order of the print statements will be non-deterministic because Task 1 and Task 2 run concurrently and may be scheduled in any order, even if they are isolated to the same actor.
> program()
Start
Task 2
Task 1
Exit
> program()
Start
Task 1
Task 2
Exit
Let’s now update the program to use Task.effect:
func program() {
Console.print("Start")
Task.effect(name: "Task 1") {
Console.print("Task 1")
}
Task.effect(name: "Task 2") {
Console.print("Task 2")
}
Console.print("Exit")
}
This won’t change the runtime behaviour of production code, but in the test we can gain control over task scheduling:
@Test
func enqueueInOrder() async throws {
try await withTestHandler {
program() // 1 | Queue: [program]
} test: { effect in
try await effect.expect(\.Console.print) { #expect($0 == "Start") } // 2
try await effect.expectTask("Task 1", action: .enqueue) // 3 | Queue: [program, Task 1]
try await effect.expectTask("Task 2", action: .enqueue) // 4 | Queue: [program, Task 1, Task 2]
try await effect.expect(\.Console.print) { #expect($0 == "Exit") } // 5 | Queue: [program, Task 1, Task 2]
try await effect.expect(\.Console.print) { #expect($0 == "Task 1") } // 6 | Queue: [Task 1, Task 2]
try await effect.expect(\.Console.print) { #expect($0 == "Task 2") } // 7 | Queue: [Task 2]
}
}
program()is added to the serial test execution queue.- The first
print("Start")is handled normally. - The test is suspended, awaiting the
Taskeffect produced by the program via theexpectTaskexpectation. When the program creates Task 1, the effect is intercepted and handled by the test with the.enqueueaction, which appends the task to the end of the serial execution queue, afterprogram(). - The same happens with Task 2, which is appended after Task 1.
- The second
print("End")is handled normally,program()exits its scope, and is popped from the execution queue. - Task 1 is executed, performing
print("Task 1"), which is expected by the test. Task 1 exits its scope and is popped from the execution queue. - Task 2 is executed, performing
print("Task 2"), which is expected by the test. Task 2 exits its scope and is popped from the execution queue.
Let’s write another test where the order of these tasks is swapped:
@Test
func enqueueInOrder() async throws {
try await withTestHandler {
program() // | Queue: [program]
} test: { effect in
try await effect.expect(\.Console.print) { #expect($0 == "Start") }
let task1 = try await effect.expectTask("Task 1", action: .suspend) // 1 | Queue: [program]
let task2 = try await effect.expectTask("Task 2", action: .suspend) // | Queue: [program]
task2.enqueue // 2 | Queue: [program, Task 2]
task1.enqueue // | Queue: [program, Task 2, Task 1]
try await effect.expect(\.Console.print) { #expect($0 == "Exit") } // | Queue: [program, Task 2, Task 1]
try await effect.expect(\.Console.print) { #expect($0 == "Task 2") } // 3 | Queue: [Task 2, Task 1]
try await effect.expect(\.Console.print) { #expect($0 == "Task 1") } // | Queue: [Task 1]
}
}
- In this test, when we intercept
Taskeffects, we handle them with the.suspendaction and store them in variables. - We then explicitly enqueue
task2first andtask1second, swapping their order in the execution queue:Execution Queue: [program, Task 2, Task 1] - When the tasks are executed, we expect the corresponding
printeffects to arrive in the order"Task 2"and then"Task 1".
withTestHandler provides a taskHandling argument that can be set to .automaticallyEnqueue, which enqueues tasks onto the serial queue in order of creation without requiring the test block to intercept them. Effectively, this flattens arbitrarily nested task hierarchies into a single serial queue:
@Test
func nestedTasks() async throws {
try await withTestHandler(taskHandling: .automaticallyEnqueue) {
Task.effect(name: "Task 1") {
Console.writeLine("Task 1")
Task.effect(name: "Task 3") {
Console.writeLine("Task 3")
}
}
Task.effect(name: "Task 2") {
Console.writeLine("Task 2")
Task.effect(name: "Task 4") {
Console.writeLine("Task 4")
}
}
} test: { effect in
try await effect.expect(\.Console.writeLine) { #expect($0 == "task 1") }
try await effect.expect(\.Console.writeLine) { #expect($0 == "task 2") }
try await effect.expect(\.Console.writeLine) { #expect($0 == "task 3") }
try await effect.expect(\.Console.writeLine) { #expect($0 == "task 4") }
}
}
You can use taskHandling: .suspend to intercept every produced task and schedule them explicitly in whatever order you need. This does not violate the Swift Concurrency runtime rules because tasks can only be scheduled after they have been created and intercepted by the test handler. For example, you cannot schedule Task 4 before Task 2 because Task 2 is Task 4’s parent. However, you can exercise different orderings of unrelated tasks, which is both possible and expected in a production environment.
Effect Scopes
Swift Effect uses TaskLocals to install Effect Handlers for the scope they wrap. This means that if we create an instance of a program like this:
struct Program {
func run() {
Console.print("Hello")
}
}
...and then pass the program instance into different scopes, the behaviour of each program.run() call will depend on the effect handlers installed in the calling scopes.
func main() {
let program = Program()
with {
Print { Console.print($0.uppercased()) }
} perform: {
program.run() // in scope with custom Print
}
program.run() // in global scope
}
main()
> HELLO
> Hello
But sometimes we may want to pass around program instances with certain handlers “baked in”. We can do this in two ways:
- Using simple function composition, by capturing the program and the handler in a reusable closure:
func main() {
let program = Program()
let run = {
with {
Print { Console.print($0.uppercased()) }
} perform: {
program.run()
}
}
run() // uppercased Print is baked-in, global handler won't see print effects
with {
Print { Console.print($0.lowercased()) }
} perform: {
run() // still handled by the uppercased Print because it's inner-most, the outer lowercased Print has no effect here.
}
}
main()
> HELLO
> HELLO
- By using the
@EffectScopemacro to make all methods defined within theProgramtype capture the currently installed effect handlers at initialisation time:
@EffectScope
struct Program {
func run() {
Console.print("Hello")
}
}
func main() {
let program = with {
Print { Console.print($0.uppercased()) }
} perform: {
Program()
}
program.run()
program.run()
}
main()
> HELLO
> HELLO
With this mechanims, we can reproduce traditional Dependency Injection patterns. This is another example of how the Minimal but General design of effects and effect handlers enables complex abstractions and workflows to be built from simple, composable primitives.
More Examples
You can find additional examples inside EffectPlaygound
FAQ
1. How is programming with Effects different from Dependency Injection?
While there are many similarities between Effects/Effect Handlers and traditional Dependency Injection, the differences between them have a profound effect (pun intended) on the way we model, structure, and reason about our programs.
Where the abstraction lives
DI: Abstractions live at the object and type level. You pass around concrete objects (e.g. APIService) within your code. Your programs directly call methods on these objects and interact with their state. While injected objects usually sit behind an interface (e.g. a protocol or a struct with closures), these object-/type-level abstractions often leak implementation details by strongly implying specific expected behavior. Such interfaces often serve merely as test-only scaffolding to allow injection of mocks, which themselves tend to become over-specified in order to satisfy the implied behaviour. This, of course, is not an inherent issue with DI, and with sufficient engineering discipline, traditional DI can avoid all these problems.
Effects: Abstractions live at the operation level. Programs are built around abstract operations such as Console.print, Logging.log, HTTP.data(for: url), UI.fontSize, etc. While these operations are namespaced by the domains they belong to, there is no implied behaviour or stateful relationship between individual operations unless such relationships are expressed explicitly via shared input/output data, for example AudioRecording.start(fileURL: fileURL); AudioRecording.stop(fileURL: fileURL). At this more granular level of abstraction, programmers are incentivised to model behaviour more generically, with operations and their relationships expressed explicitly through data flow. This leads to more procedural, portable, and composable code that can be recombined under different Effect Handler interpretations.
Composability:
DI: Mixing and customising the behaviour of multiple objects, types, or services often requires creating bespoke object hierarchies. For example, extending APIService with custom caching behaviour typically involves introducing a higher-level coordinator such as CachedAPIService, which combines the behaviour of wrapped services in an ad hoc manner.
Effects: Multiple effect handlers for the same effect can be installed on the same call stack and can extend each other in a generic way, without the need for direct coordination. For example, extending the HTTP.data(for: URL) effect with custom caching behaviour can be achieved by installing multiple independent HTTP effect handlers, each intercepting and augmenting the operation as needed.
func main() {
with {
HTTP.Data { url in
... URL session
}
} perform: {
module()
}
}
func module() {
let localCache = ...
with {
HTTP.Data { url in
if let data = localCache(url) {
return data // return cached data immediately
} else {
let data = await HTTP.data(for: url) // yield to next handler
localCache.set(data, url) // update cache
return data
}
}
} perform: {
program()
}
}
func program() {
let data = await HTTP.data(for: url)
}
In this example, neither the program nor any of the effect handlers are aware of each other’s existence, and any handler can be added or removed at will, thereby changing the caching policy without modifying any other part of the program.
Testing:
DI: You define and configure test doubles, such as mocks and stubs, ahead of time and inject them into the system under test in order to exercise logic in isolation from concrete dependency implementations.
Effects: You directly intercept, inspect, and resume individual observable effectful operations as they are performed by the system under test, exercising logic by interpreting that behaviour in the context of a given step within the test.
2. Aren’t effects just global functions? Aren’t globals bad?
Yes and no. Effects are indeed modelled as global functions, but they are namespaced, scoped, thread-safe, and dynamically bound global functions.
Namespaced
All effects are namespaced by the domain they belong to. This minimises name collisions with other global systems, particularly system-provided ones.
Scoped
Each effect’s behaviour is scoped to the effect handlers installed in the call-stack hierarchy at the point where the effect is performed.
Thread-safe
Effect handler scopes are thread-safe and cannot escape into unrelated execution contexts. This invariant is guaranteed by the underlying TaskLocal mechanism to which effect handlers are bound.
Dynamically bound
When an effect is performed, its behaviour is resolved dynamically at runtime by matching it to the nearest applicable effect handler in the thread-safe scope stack. Together, these mechanisms eliminate most of the problems traditionally associated with globals, such as implicit shared mutable state, lack of composability, and inability to customise or extend behaviour safely.
It is still possible to violate these invariants by using unsafe global state behind effect implementations. However, this is a universal risk in impure programming and applies equally to dependency injection, effects, or any other architectural pattern in an inherently impure language such as Swift.
📝 Share your Feedback
Please share your feedback, questions, and ideas in Discussions
Similar projects
- Probing is a cool library with similar goals that provides "programmable breakpoints" that enable powerful testing of complex Swift Concurrency workflows and stateful programs.
- swift-dependencies While more focused on traditional dependency injection approaches, this library makes heavy use of
TaskLocals for installing dependencies into scopes, which makes it closely related to this project, and indeed a significant source of inspiration. - swift-composable-architecture The most widely adopted architectural framework that ships with a first-class effect system out of the box. While incredibly powerful for effect control and testing, it unfortunately requires a fundamental restructuring of otherwise “normal” procedural programs into a unidirectional data-flow model. Which is one of the key problems this project aims to solve.
Author
This project was created and is maintained by Alex Ozun
- About & Contacts: https://swiftology.io/about/
- Blog: https://swiftology.io
- Talks: https://swiftology.io/videos/