Kiwicode.dev blog

Unapplied Actor-isolated Method References

· Kinga Wilczek

Haven’t you been missing lately a super versatile mechanism - the heart of tons of abstractions - like unapplied method references for actors? E.g. for certain dependency injection setups? Because I have. But here’s the good news: the fix is already chilling in Swift’s repo, waiting patiently on the main branch. No word yet on which version it’ll land in.

If this bug has you itching to tidy up your knowledge on the topic like it did for me, come take a peek at the infographic below! It sneaks in a quick summary of the issue itself, the isolation context, and those unapplied/partially applied method references (in case that’s new to you).

Unapplied & Partially Applied Actor Method References

BankAccount.deposit(amount:) · BankAccount().deposit

SE-0313 · broken since Swift 5.9 · PR #86904 merged to main

HOW ACTOR METHOD REFERENCES WORK
Regular — non-actor
// Unapplied (type-level):
let fn = String.uppercased
// (String) -> () -> String

// Partially applied (instance):
let fn2 = "hello".uppercased
// () -> String
// Works in any context
Unapplied — type-level (SE-0313)
actor BankAccount {
    func deposit(amount: Double) { ... }
}  // implicit: isolated self
let fn = BankAccount.deposit(amount:)
// (isolated BankAccount)
//   -> (Double) -> Void
// SE-0313 — 'isolated' in type
Partially applied — instance-bound
actor BankAccount {
    func deposit(amount: Double) { ... }
}
func apply(account: isolated BankAccount) {
    let fn = account.deposit
    // (Double) -> Void
    // ✓ already isolated to account
}
THE REGRESSION
// — Unapplied type-level reference (the SE-0313 regression):
let fn = BankAccount.deposit(amount:)
// warning/error: call to actor-isolated instance method
//   'deposit(amount:)' in a synchronous nonisolated context

// Root cause: broken thunk emission for isolated parameters
// Fixed by PR #86904.

// — Instance-bound in nonisolated context:
let fn2 = BankAccount().deposit
// error: can not be partially applied  (PR #34051)
// ✓ correct only when called from within that actor
SCOPE — WHICH METHOD KINDS ARE AFFECTED
nonisolated — always works ✓
nonisolated func withdraw(
    amount: Double) { ... }

// Both forms ✓:
BankAccount.withdraw  ✓
BankAccount().withdraw  ✓

// No 'isolated' in type — never affected
sync isolated — reference form matters
// implicit isolated self:
func deposit(
    amount: Double) { ... }

BankAccount.deposit  ❌→✓
// thunk bug — Fixed by PR #86904

actorInstance.deposit
// ✓ already isolated to that actor
//   (actorInstance can be self)
// ❌ outside or on a different actor
async isolated — works ✓ (with one exception)
// also isolated by default:
func load() async { ... }

BankAccount.load  ✓
BankAccount().load  ✓
// async hop is available — both forms work

// exception: @preconcurrency + global actor
//   (e.g. SomeView: View) fires a warning
RESTORED BY PR #86904 (merged to main — not yet in any release)
// — Unapplied type-level reference (now fixed):
let fn = BankAccount.deposit(amount:)
// ✓ type: (isolated BankAccount) -> (Double) -> Void

// Sync usage within actor context (no await):
extension BankAccount {
    func batch(_ amounts: [Double]) {
        let op = BankAccount.deposit(amount:)
        amounts.forEach { op(self)($0) }  // ✓ sync
    }
}

// — Partial application: context-dependent
// actorInstance.deposit  ✓  (already isolated to it)
// BankAccount().deposit  ❌  (outside or different actor)
⚙️ Try it yourself: install the main branch toolchain from swift.org/install
WHAT 'isolated' MEANS IN THE TYPE SIGNATURE
Breaking down the type signature
// BankAccount.deposit(amount:) has type:
// (isolated BankAccount) -> (Double) -> Void
//  ^^^^^^^^^^^^^^^^^^^^^^^^^

// 'isolated BankAccount':
//   The actor instance you supply as 1st argument.
//   'isolated' means the call runs INSIDE that
//   actor — synchronously, no await needed.

// -> (Double) -> Void  (the rest of the args):
//   Curried: pass the actor → get back a function
//   that accepts the original argument(s).
How to call it — inside the actor
// Inside the actor — pass self as the instance:
extension BankAccount {
    func batch(_ amounts: [Double]) {
        let op = BankAccount.deposit(amount:)
        // op type: (isolated BankAccount)
        //              -> (Double) -> Void
        amounts.forEach {
            op(self)($0)  // ✓ sync, no await
            // same as: self.deposit(amount: $0)
        }
    }
}
REFERENCES

If you’re keen to dive deeper (but not too deep) into the “thunk emissions” puzzle and sink your teeth into it, this infographic below will help you wrap your head around the whole thing.

Actor Method Thunks — The Compiler Bug Behind SE-0313

BankAccount.deposit(amount:) → compiler-generated wrapper

Root cause of regression since Swift 5.9 · Fixed by PR #86904

WHAT IS A THUNK?
You write this
// Any method can be used as a value:
let fn = String.uppercased
// type: (String) -> () -> String

fn("hello")()
// → "HELLO"
Compiler generates (conceptually)
// Compiler generates (conceptually):
func __thunk(_ s: String)
        -> () -> String {
    return { s.uppercased() }
}
// Nonisolated — no actor involved
THE ACTOR THUNK — WHAT SHOULD BE GENERATED
You write this
// Actor-isolated method reference:
let fn = BankAccount.deposit(amount:)
// (isolated BankAccount)
//   -> (Double) -> Void

// 'isolated' stays in the type
Compiler should generate (conceptually)
// Compiler should generate (conceptually):
func __thunk(
    account: isolated BankAccount
) -> (Double) -> Void {
    return { amount in
        account.deposit(amount: amount)
        // ✓ sync on account's executor
    }
}
// Thunk isolation = first param's isolation
THE BUG — ISOLATION COULDN'T BE COMPUTED
// Step 1: Parse function type
//   (isolated BankAccount) -> (Double) -> Void  ✓

// Step 2: Determine thunk isolation  ← BROKEN HERE
//   Isolated first param → thunk should inherit isolation
//   COMPILER: no machinery to propagate isolated info  ❌

// Step 3: Emit IR
//   ❌ Wrong IR / broken types at call site
//   ❌ error/warning: 'actor-isolated method deposit(amount:)
//      in synchronous nonisolated context'

// PR description: 'it won't be possible to compute
//   a correct isolation for the thunk'
Root cause: missing machinery to propagate isolated param → thunk isolation
RESTORED BY PR #86904 (merged to main — not yet in any release)
What the PR added
// PR #86904 added the missing machinery:
// 1. Detect isolated first parameter
//    in the function type
// 2. Propagate its isolation to the thunk
//    → thunk isolation = isolated(account)
// 3. Emit correct IR:
//    executor hop ✓
//    isolated self forwarded ✓
//    correct type in output ✓
Unapplied form now compiles
// Unapplied form now compiles:
let fn  = BankAccount.deposit(amount:)
// ✓ (isolated BankAccount) -> (Double) -> Void

// Partial application: context-dependent
// actorInstance.deposit  ✓  (already isolated to it)
// BankAccount().deposit  ❌  (from outside)

// Thunk fixed for unapplied form ✓
// → SE-0313 unapplied refs work as intended
REFERENCES