Swift 6 introduced many new features and keywords for concurrency. While many of these might be rarely used in daily development, encountering specific scenarios without understanding these new concepts can lead to hitting a wall, even with AI assistance. In this post, I will walk through a concurrency issue encountered during development testing to introduce how to utilize @isolated(any) and the #isolation macro. These tools enable function isolation inheritance, allowing the compiler to automatically infer the execution context of closures.
Did the Compiler Ignore My Default Actor?
After Swift 6.2 introduced Default Actor Isolation, I have been actively exploring scenarios where it fits best. Since I am accustomed to organizing different functional code into separate Targets via SPM—and Default Actor Isolation is configured on a per-Target basis—I usually add the following settings when building Targets for view feature sets or state/data flow feature sets:
swiftSettings: [
.swiftLanguageMode(.v6),
.defaultIsolation(MainActor.self),
.enableExperimentalFeature("NonisolatedNonsendingByDefault"),
]
This effectively reduced the mental burden during coding and testing. One immediate result is that in these Targets, I haven’t needed to explicitly control the execution domain of closures using the following pattern for a long time:
// Used to be necessary, now usually omitted
{ @MainActor in
....
}
However, I recently encountered an unexpected situation while testing a simple dependency injection library I wrote. I was forced to add @MainActor in, otherwise the code would not 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
...
}
}
The compiler produced the error: “Main actor-isolated property ‘noteID’ can not be referenced from a Sendable closure”.
Although I could resolve the issue by manually adding @MainActor in, it raised a question: Given that the Target’s Default Actor is already set to MainActor, why couldn’t the compiler automatically infer that the operation closure should run on the MainActor?
Analyzing the Problem
The withDependencies function used in the code above is a simplified version I wrote mimicking the API style of swift-dependencies: TinyDependency. It consists of just over 100 lines of code with no third-party dependencies, which is sufficient for my personal use cases.
It is declared as follows:
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 in this Target are all @MainActor by default, the Swift compiler should ostensibly infer that the operation async closure received by withDependencies in the test method also runs on the MainActor.
However, the reality is that the compiler did not infer this as expected. It seems to consider that this closure might execute across actors, thus preventing access to MainActor-isolated properties. While I can use @MainActor in for a runtime enforcement, is there a way to achieve automatic handling (inference) at compile time?
Letting Closures Inherit Isolation
To solve this problem, we can leverage two new features introduced in Swift 6.
First is @isolated(any), proposed in SE-0431. Its primary purpose is to address the lack of sufficient information for the compiler to accurately determine isolation when functions are passed as values.
When a function type is annotated with @isolated(any), it carries information about the isolation domain of its caller. Although we typically don’t read this information directly in code, the compiler uses it to infer the runtime isolation environment.
By modifying withDependencies as follows, the previous compilation error disappears:
public func withDependencies<R>(
_ updateValuesForOperation: (inout DependencyValues) -> Void,
operation: @isolated(any) () async throws -> R // Added @isolated(any)
) async rethrows -> R {
var dependencies = DependencyValues.current.copy()
updateValuesForOperation(&dependencies)
return try await DependencyValues.$_current.withValue(dependencies) {
try await operation()
}
}
By adding @isolated(any), the compiler automatically perceives and inherits the caller’s isolation domain when handling the operation closure. Since the test Target’s default isolation is MainActor, the compiler infers that the closure also runs on the MainActor, passing compile-time safety checks without needing to manually write @MainActor.
However, @isolated(any) is primarily a decoration for function types. If we want to design this as a general-purpose library function, we might not want to be limited to this specific type of inference. In this case, we can adopt the Inheritance of actor isolation scheme proposed in SE-0420:
public func withDependencies<R>(
isolation: isolated (any Actor)? = #isolation, // SE-0420: Isolation Inheritance
_ 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()
}
}
By adding an isolation parameter to withDependencies and combining it with the #isolation macro, we can explicitly inform the function of the operation’s isolation context in advance.
The isolation parameter offers three possibilities:
- nil: The function is dynamically non-isolated, which is consistent with the default behavior when this parameter is absent.
- Global Actor: The function is dynamically isolated to a specific global actor. For example, passing
MainActor.sharedcan replace@MainActor ininside the closure, moving the check from runtime to compile time. - Caller’s Isolation: If
withDependenciesis called from within an Actor instance, it inherits that Actor’s isolation.
To achieve the effect of “defaulting to inheriting the caller’s isolation,” we use the #isolation macro as the default argument:
/// Produce a reference to the actor to which the enclosing code is
/// isolated, or `nil` if the code is nonisolated.
@freestanding(expression) public macro isolation<T>() -> T = Builtin.IsolationMacro
It automatically captures the current isolation information and passes it to the isolation parameter. With these adjustments, my test code runs correctly, and the code remains clean without needing any manual isolation annotations.
Obscure but Useful
The multitude of new concurrency keywords added in Swift 6 can indeed be confusing for many developers. When reading Proposals or technical articles, many keywords often feel “baffling” or even “superfluous” (like painting the lily).
Take isolated(any) as an example. I first learned about it from Matt Massicotte’s article and even recommended it in my Weekly Newsletter. Yet, even so, reading about it without a real-world context often felt like the concepts just didn’t stick. If I hadn’t hit a wall in this specific test scenario—combined with my habit of omitting @MainActor in—I probably would never have truly grasped its utility.
However, once you actually use them, you realize that these features truly embody the spirit of Swift 6’s pursuit of compile-time safety. They might be obscure, but in the right context, they are incredibly useful!