`@isolated(any)`: Isolation-Aware Function Types

`@isolated(any)`: Isolation-Aware Function Types

@isolated(any): Isolation-Aware Function Types

// The three ways production code types a "run-me-later" callback:

// 1. Unisolated — the framework's thread decides where your body runs.
func subscribe(_ listener: @escaping (Event) -> Void)

// 2. Pinned to a global actor — delivery always crosses an executor,
//    and every subscriber is treated as if it lives on the main thread.
func subscribe(_ listener: @escaping @MainActor (Event) -> Void)

// 3. Dynamic — the function type carries the caller's isolation.
func subscribe(_ listener: @escaping @isolated(any) (Event) -> Void)

Line 1 runs wherever the framework happens to be executing — right for pure compute, a data race for UI. Line 2 is correct only when the subscriber genuinely cannot leave the main thread; everyone else pays a hop that was never needed. Neither expresses the contract the author actually wants: run wherever the caller registered. That is the contract @isolated(any) — a Swift isolation function type from SE-0431, shipping since Swift 6.0 — makes part of the type itself, and it exists so authors stop papering over the absence with Task { @MainActor in } hops.

Wireframe diagram showing an event dispatcher carrying a callback into the subscriber's own isolation domain, beside the faint speculative-hop arc it replaces.

The Verdict

  • @isolated(any) is the missing primitive for delegate and callback APIs. It lets the function value carry the isolation of whoever registered it, so delivery lands on the subscriber’s executor instead of the executor the library author guessed.
  • It removes speculative hops, not isolation. Adopt it to delete Task { @MainActor in } wrappers from event sources, observers, and per-requirement protocol callbacks — but read the hidden cost: callbacks now run on the caller’s executor, which re-opens the actor reentrancy questions you stopped thinking about.
  • It is type-erasure for isolation, with all the sendability rules that implies. Calls must be awaited even when the function is synchronous, the value is not implicitly @Sendable, and stripping it to a plain function type is a warning that Swift 6 mode is moving to an error.
  • Keep a static scalar for genuinely pinned work. Where an API must run on the main thread regardless of subscriber, @MainActor remains the right annotation; @isolated(any) is for “wherever you live,” not “the one place it must run.”

Where the Contract Was Lost

The mechanics: every actor-isolated function runs on its own executor, and crossing to it is an await. A closure that captures nothing and annotates nothing inherits its creation context’s isolation. All of that is real, and none of it is in the type system for a value you store and hand around.

Look at any foundation callback — URLSession, NotificationCenter, a delegate — and ask what a stored callback knows about itself. An @escaping (Event) -> Void says nothing about where its body may touch actor-isolated state. An @async version erases isolation the same way any erases a concrete type: the value still has an isolation at runtime, but nothing in the type or the API surface can recover it. Matt Massicotte’s writeup on NSHipster frames it precisely: async bought flexibility and lost information, and @isolated(any) is the recovery mechanism.

That loss had one practical escape hatch, and most senior codebases took it.

The Speculative Hop

actor AppEventBus {
    // Before: the callback type has no isolation slot, so we invent one.
    private var handlers: [UUID: @Sendable (AppEvent) -> Void] = [:]

    func subscribe(_ handler: @escaping @Sendable (AppEvent) -> Void) -> UUID {
        let id = UUID()
        // We can't say "run wherever they live," so we guess MainActor and
        // bounce every delivery through a fresh task. UI code works; anyone
        // on a worker actor pays a hop they never asked for — and a
        // @Sendable body can't even close over their actor state to find out.
        handlers[id] = { event in
            Task { @MainActor in
                handler(event)
            }
        }
        return id
    }
}

This compiles, it is race-free, and it is wrong for a predictable fraction of subscribers. A consumer on a model actor registers a callback expecting delivery in its own context; instead the bus injects an executor crossing, then the consumer bounces work back to its actor manually to reach its own state. Two hops where zero were needed. On the main thread the same pattern defers delivery through the generic executor before it reaches the main queue — observable as ordering drift relative to other main-actor work.

The core defect is the guess. The author does not know MainActor is right; they know something must ensure a sane thread, and the language gave them no way to say “the pair of (handler, wherever-it-was-created).”

What @isolated(any) Actually Carries

SE-0431 adds a function type attribute — you cannot write @isolated(any) func on a declaration, and it cannot be combined with another isolation specifier like @MainActor or an isolated parameter. A value of this type dynamically holds the formal isolation of the function expression that produced it, which is one of three things, readable through a special read-only property:

let isolation = handler.isolation   // (any Actor)?
  • nil — the function is dynamically non-isolated.
  • MainActor.shared — it was isolated to a global actor.
  • a concrete any Actor reference — it was isolated to an instance actor.

That value is the same thing #isolation produces, so it is directly comparable against your own context’s isolation at runtime. Because the compiler cannot know which of the three applies at the call site, the invocation is always treated as crossing an isolation boundary: it must be awaited even when the function is synchronous, and arguments and results follow the usual sendability rules for cross-isolation calls. The function value itself is not implicitly @Sendable — the LSG removed that rule from the original proposal, since region-based isolation made it unnecessary and it blocked legitimate non-Sendable uses. Add @Sendable explicitly where the callback will cross as a stored value, exactly as you would for any other escaping closure.

Conversion behaves the way erasure should. A @MainActor function converts to @isolated(any), carrying MainActor.shared dynamically; a non-isolated function converts carrying nil; a function with an isolated parameter does not convert at all. The reverse — dropping @isolated(any) back to a plain synchronous type — is where the hidden footgun lives:

// Swift 6.2 warns: converting '@isolated(any) function of type
// "@isolated(any) (Event) -> Void' to synchronous function type
// '(Event) -> Void' is not allowed; this will be an error in a future
// Swift language mode.
//
// Bridging to a legacy API strips the isolation. That is a data race
// reintroduced on purpose, so the compiler is signalling intent loudly.

Every task-creation API in the standard library now takes an @isolated(any) operation, and — the pragmatically important part — the initializer reads the dynamic isolation and synchronously enqueues the task directly onto that executor. No stop on the generic concurrent pool, no re-scheduling. That direct enqueue is what restores ordering for tasks that immediately hop, and it is the reason Task(handler:) beats Task { await handler(...) } in the refactor below.

Refactoring the Delegate API

The move is a type change and one writer change; every subscriber site simplifies.

actor AppEventBus {
    // After: the callback's isolation rides along in the type.
    private var handlers: [UUID: @isolated(any) @Sendable (AppEvent) -> Void] = [:]

    func subscribe(
        _ handler: @escaping @isolated(any) @Sendable (AppEvent) -> Void
    ) -> UUID {
        let id = UUID()
        // Reading isolation is free and costs nothing here, but it gives
        // us a hook for diagnostics and for assertion in tests.
        handlers[id] = handler
        print("subscriber registered on:", String(describing: handler.isolation))
        return id
    }

    func deliver(_ event: AppEvent) async {
        // Static isolation unknown -> treated as a crossing, so `await`
        // is mandatory even though the handler itself is synchronous.
        for handler in handlers.values {
            await handler(event)
        }
    }
}

The subscriber keeps its original isolation — no wrapping closure, no executor invented by the library:

// Consumer on a model actor — delivery now arrives HERE, once, instead of
// bouncing main-ward and being bounced back by hand.
func register() async {
    await bus.subscribe { event in
        apply(event)          // runs on this actor via its own executor
    }
}

Two standard-library shapes follow from the same change. Task(operation:) reads the passed value’s isolation and enqueues straight to the right executor, so a bus that must fire-and-forget preserves ordering instead of losing it behind an intermediate closure:

func deliverDetached(_ event: AppEvent) async {
    for handler in handlers.values {
        // Pass the value straight through: direct enqueue on the handler's
        // executor. Wrapping it in a second closure would reintroduce the
        // very extra scheduling step we are removing.
        Task(operation: handler)
    }
}

And in a protocol, @isolated(any) is the fix for the “MainActor protocol callback” trap — a protocol pinned @MainActor only because its callbacks touch UI, which then drags every conformance onto the main actor:

// Before: whole-protocol @MainActor so the callback flavor is safe on
// the main thread. Every witness — even a presence tracker that never
// touches UI — is now a MainActor type, and every call crosses.
@MainActor
protocol ChatServiceDelegate: AnyObject {
    func chatService(_ service: ChatService, didReceive message: ChatMessage)
    func chatService(_ service: ChatService, didRefresh presence: [Contact])
}
// After: requirements stay nonisolated; the callback that must "run where
// the observer lives" carries that isolation itself. Main-UI observers
// get MainActor delivery; background observers get theirs. No guess, no
// whole-type pinning — per-requirement (SE-0420) plus this.
protocol ChatService {
    func observe(
        _ handler: @escaping @isolated(any) @Sendable (ChatMessage) -> Void
    ) async -> AnyCancellable
}

The Reentrancy Audit

This is the cost the Verdict is warning about, and it is the reason adoption is not a mechanical find-and-replace. Actors grant mutual exclusion only up to the next await — the suspension gap. When a callback runs on your executor, and your executor is an actor’s, that callback now executes interleaved with your own operations. Two failure classes follow.

You can be reentered mid-operation. The bus’s deliver awaits handler(event); an observer whose delivery throws a Task back at the bus — or an observer that registers from inside another delivery — can run deliver again while the first deliver is suspended, mutating handlers under an in-progress for ... in handlers.values. Dictionary mutation during enumeration is a runtime trap; the logic equivalent is reading a cache entry, suspending, and resuming against the new reality. Every callback site that was previously funneled through a known queue needs a pass for this. Guard with in-flight registries or pre-snap a copy before iterating.

Ordering guarantees are only as strong as the value you pass. The direct-enqueue guarantee holds when you hand Task the @isolated(any) value itself. The moment you wrap it — Task { await handler(event) }, a closure created in your context — the wrapped closure carries your isolation, not the subscriber’s, and you have reintroduced the two-step scheduling path (and possibly a different target executor than the one the subscriber asked for). This is the NSHipster point in compressed form: two steps vs one, and the ordering hangs on which one you wrote.

There is a third, more obscure class that matters for framework authors: custom executors whose enqueue runs jobs synchronously during the enqueue call. The SE-0431 pitch thread calls this out explicitly — direct enqueue is the first language feature to exercise the runtime’s reentrant-task path in the general case, and executors that block inside enqueue were previously insulated by the generic-pool hop. If you ship a SerialExecutor with synchronous enqueue behavior, validate it under @isolated(any) delivery before rolling out.

The audit checklist is short: every place the callback can synchronously call back into its own executor is a reentrancy point; pre-snapshot collections before iterating them under await; and assert handler.isolation == #isolation in debug where a consumer’s contract is “you must deliver to my context.”

When To Adopt, And When Not To

Adopt @isolated(any) at API surfaces: event sources, observer registries, delegate callbacks whose delivery context belongs to the subscriber, protocol requirements that are users’ callbacks. It is also the correct primitive for the @MainActor protocol case — keep the protocol nonisolated and let each witness’s callback carry its own isolation, per-requirement and per-subscriber, rather than pinning the whole type.

Keep a concrete annotation when the executor is genuinely non-negotiable: an API whose contract is “whole-protocol @MainActor” (the SwiftUI View case) should stay exactly that. And do not reach for it in hot inner loops where both sides are statically known — every @isolated(any) call is conservatively treated as a boundary crossing, so a callback invoked millions of times at known isolation is better served by the concrete attribute and its optimized lowering. Erasure has a runtime cost; buy it where the semantics earn it.

Ready for more depth?

Master these concepts with our structured technical roadmap.

View Roadmap