🚀 Swift 6 Concurrency: Mastering @isolated(any) and #isolation
Swift 6 introduces a suite of powerful new concurrency features and keywords. While many of these may not see daily use, encountering specific edge cases without a solid grasp of these concepts can leave you deadlocked—even with the best AI assistance.
In this post, I’ll walk through a real-world concurrency challenge I encountered during development. I’ll demonstrate how to leverage @isolated(any) and the #isolation macro to implement isolation inheritance, enabling the compiler to automatically infer the execution context of your closures.
Default Actor is ignored by the compiler?
Since the rollout of Default Actor Isolation in Swift 6.2, I’ve been actively exploring its practical applications. In my workflow, I typically decouple different functionalities into separate targets via SPM. Since Default Actor Isolation is configured on a per-target basis, I usually apply the following settings when building View feature sets or State/Data-flow modules:
swiftSettings: [
.swiftLanguageMode(.v6),
.defaultIsolation(MainActor.self),
.enableExperimentalFeature("NonisolatedNonsendingByDefault"),
]This has significantly reduced the mental overhead during coding and testing. One immediate and palpable benefit is that, within these targets, I haven’t had to use explicit @MainActor annotations in my closures for quite some time:
// We normally write like this, but can be ignored for now
{ @MainActor in
....
}However, while conducting tests with a lightweight dependency injection library I authored, I ran into some unexpected behavior. I found myself forced to manually add @MainActor in—otherwise, the code simply wouldn't compile:
@Test
func switchID() async {
let noteID = UUID()
await withDependencies {
$0.appSettings = AppSettingsTestHelpers.makeMockSettings()
} operation: {
@Dependency(\.appSettings) var settings
// Error:Main actor-isolated property 'noteID' can not be referenced from a Sendable closure
settings.noteID = noteID
...
}
}While manually adding @MainActor in resolved the issue, it left me puzzled: Given that the Target's Default Actor is already set to MainActor, why is the compiler failing to automatically infer that the operation closure should also run on the MainActor?
Identify the problem
The withDependencies function used above is part of TinyDependency, a simplified version I wrote that mimics the API style of swift-dependencies. Clocking in at just over a hundred lines of code with zero third-party dependencies, it perfectly suits all my personal project needs.
public func withDependencies<R>(
_ updateValuesForOperation: (inout DependencyValues) -> Void,
operation: () async throws -> R
) async rethrows -> R {
var dependencies = DependencyValues.current.copy()
updateValuesForOperation(&dependencies)
return try await DependencyValues.$_current.withValue(dependencies) {
try await operation()
}
}Based on my understanding of Default Actor Isolation, since the protocols, types, and methods declared in this target are already marked as @MainActor by default, the Swift compiler should—in theory—infer that the operation asynchronous closure passed to withDependencies also runs on the MainActor.
However, the reality is quite different. The compiler fails to make this inference; instead, it treats the closure as potentially non-isolated (or escaping to another actor), thereby blocking access to any MainActor-isolated properties. While I could force it at runtime using @MainActor in, I found myself searching for a more elegant solution: Is there a way to let the compiler perform this inference automatically at compile-time?
Inheriting the Closure’s Isolation Context
To resolve this, we can leverage two new features introduced in Swift 6.
The first is @isolated(any), proposed in SE-0431. Its primary goal is to address the issue where, when a function is passed as a value, the compiler lacks sufficient information to accurately determine its isolation context.
When you annotate a function type with @isolated(any), that function type will "carry" the isolation information of its call site. While we rarely access this information directly in our code, the compiler uses it behind the scenes to infer the execution environment at runtime.
By updating withDependencies as shown below, the previous compilation error vanishes:
public func withDependencies<R>(
_ updateValuesForOperation: (inout DependencyValues) -> Void,
operation: @isolated(any) () async throws -> R // Add @isolated(any)
) async rethrows -> R {
var dependencies = DependencyValues.current.copy()
updateValuesForOperation(&dependencies)
return try await DependencyValues.$_current.withValue(dependencies) {
try await operation()
}
}By adding an isolation parameter to withDependencies and pairing it with the #isolation macro, we can explicitly define the isolation context for the operation closure in advance.
The isolation parameter here opens up three distinct possibilities:
nil: The function is dynamically non-isolated, consistent with the default behavior without this parameter.- Global Actor: The function is dynamically isolated to a specific Global Actor. For instance, passing
MainActor.sharedreplaces the need for@MainActor inwithin the closure, shifting the check from runtime to compile-time. - Caller’s Isolation: If
withDependenciesis called from within an Actor instance, it will inherit that Actor’s specific isolation context.
To achieve the effect of "defaulting to the caller's isolation context," we use the #isolation macro as the default argument value:
/// Captures a reference to the Actor isolation of the current code, or returns nil if the code is non-isolated.
@freestanding(expression) public macro isolation<T>() -> T = Builtin.IsolationMacroIt automatically captures the current isolation context and passes it to the isolation parameter. Following these adjustments, my test code not only compiles and runs perfectly but also eliminates the need for any manual isolation annotations. The result is a much cleaner and more concise codebase.
Some tips
The sheer number of new concurrency keywords in Swift 6 can indeed be overwhelming. When skimming through Proposals or technical articles, many of these keywords can feel "mysterious" or even "redundant" at first glance.
Take isolated(any) as an example: I first learned about it through an article by Matt Massicotte and even recommended it in my newsletter. Yet, if I hadn't hit a wall during this recent testing—and if I hadn't already grown accustomed to the luxury of omitting @MainActor in—I probably wouldn't have thought to use it.
Once you actually roll up your sleeves and use them, however, you realize these features truly embody Swift 6’s relentless pursuit of compile-time safety. They might seem obscure, but in the right context, they are absolute lifesavers.