Isolating closures

June 14, 2025

Problem

Whilst migrating to Swift 6 language mode, I recently encountered an issue:

I needed to reference main actor isolated code inside of a non-isolated closure.

Because withObservationTracking is from the Foundation library, I couldn't simplify modify the function defintion.

withObservationTracking {
    ViewModel() // Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
} apply: {

}

If we look at the definition of withObservationTracking, we can see apply is nonisolated.

// nonisolated closure
_ apply: () -> T

Isolating a nonisolated closure

As it turns out, we can actually isolate this "nonisolated closure" by ensuring its created from an isolated actor.

@MainActor // add this
func work() {
    withObservationTracking {
        // No more error
        // Runs on the MainActor
        ViewModel()
    } apply: {

    }
}

It's wild to me that we can get away with this. It seems to work even if the closure is @escaping.

For example, this works too:

@MainActor
final class ViewModel {
    var count: Int = 0
}

@MainActor
func testCode() {
    perform {
        let viewModel = ViewModel()
        print(viewModel.count)
    }
}

nonisolated func perform(_ work: @escaping () -> Void) {
    // ...
}

From my naive understanding, it seems Swift allows this as work doesn't cross an isolation boundary.

As soon as we update perform to the following:

// Add `@Sendable`
nonisolated func perform(_ work: @escaping @Sendable () -> Void) {
    // ...
}

We run into this issue:

@MainActor
func work() {
    perform {
        let viewModel = ViewModel()
        print(viewModel.count) // Main actor-isolated property 'count' can not be referenced from a Sendable closure
    }
}

Thats where we have to ensure our main actor code always runs on the main thread, perhaps by using a DispatchQueue.main.async:

@MainActor
func work() {
    perform {
        // No errors
        DispatchQueue.main.async {
            let viewModel = ViewModel()
            print(viewModel.count)
        }
    }
}

Summary

If we think about it — if you're already on the main actor, all non-isolated code is technically isolated to the main actor during that period.

The Swift compiler is smart enough to figure this out.

Its only when your nonisolated code might cross an isolation domain that the Swift compiler gives us a warning.