All skills
jamesrochabrun avatar

/swift-concurrency

@a3824c9

Guide for building, auditing, and refactoring Swift code using modern concurrency patterns (Swift 6+). This skill should be used when working with async/await, Tasks, actors, MainActor, Sendable types, isolation domains, or when migrating legacy callback/Combine code to structured concurrency. Covers Approachable Concurrency settings, isolated parameters, and common pitfalls.

Use this Skill: https://skilld.dev/gh/jamesrochabrun/skills/swift-concurrency

This session only. Nothing lands on disk.

referencessendable.md

≈1.5k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Sendable and Crossing Isolation Boundaries

What is Sendable?

When you pass data between isolation domains, Swift checks if it's safe. A reference to a mutable class passed from one actor to another could be modified simultaneously - exactly the data race we're preventing.

The Sendable protocol is a marker that tells the compiler "this type is safe to pass across isolation boundaries":

  • Sendable types can cross safely (value types, immutable data, actors)
  • Non-Sendable types cannot (classes with mutable state)
// Sendable - value type, each place gets a copy
struct User: Sendable {
  let id: Int
  let name: String
}

// Non-Sendable - class with mutable state
class Counter {
  var count = 0  // Two places modifying this = disaster
}

Making Types Sendable

Swift automatically infers Sendable for many types:

  • Structs and enums with only Sendable properties are implicitly Sendable
  • Actors are always Sendable (they protect their own state)
  • @MainActor types are Sendable (MainActor serializes access)

For classes, it's harder. A class can conform to Sendable only if it's final and all stored properties are immutable:

final class APIConfig: Sendable {
  let baseURL: URL      // Immutable
  let timeout: Double   // Immutable
}

@unchecked Sendable

For types that are thread-safe through other means (locks, atomics):

final class ThreadSafeCache: @unchecked Sendable {
  private let lock = NSLock()
  private var storage: [String: Data] = [:]
}

Warning: @unchecked Sendable is a promise. The compiler won't verify thread safety. Use sparingly.

The Photocopies Metaphor

When sharing information between department offices:

  • Photocopies are safe - Legal copies a document and sends it to Accounting. Both have their own copy. No conflict.
  • Original signed contracts must stay put - If two departments could both modify the original, chaos ensues.

Sendable types are like photocopies: safe to share because each place gets its own copy (value types) or because they're immutable. Non-Sendable types are like original contracts.

Non-Sendable Types Are Valuable

Non-Sendable types are also thread-safe - the compiler forces you to use them safely. They're stuck in whatever isolation domain they're created in.

Key insight: If you can remove isolation from a type, you should. Isolation is a constraint; removing it makes things more flexible.

// This works fine - no isolation needed
class MyClass {
  private var state = 0

  func someFunction() {
    print("my state is:", state)
  }
}

A non-Sendable type will be created in some isolation domain and stay there. It cannot move.

The Problem: Non-Sendable + async

Non-isolated async functions always run on the global executor (background):

class MyClass {
  private var state = 0

  func someAsyncFunction() async {
    print("my state is:", state)  // ERROR
  }
}

To call this method, the instance must already be non-isolated. This creates a type only usable from non-isolated contexts.

The Solution: Isolated Parameters

Use an isolated parameter to inherit caller's isolation:

class MyClass {
  private var state = 0

  func someAsyncFunction(isolation: isolated (any Actor)? = #isolation) async {
    print("my state is:", state)
  }
}

This function works from MainActor, custom actors, and non-isolated contexts. The #isolation default handles everything automatically.

@MainActor
class Client {
  let instance = MyClass()

  func useInstance() async {
    // Now this works! Function inherits MainActor isolation
    await instance.someAsyncFunction()
  }
}

Bonus: Isolated parameters guarantee no suspension on function entry, opening a synchronous window.

Non-optional Variant (Safest)

class MyClass {
  private var state = 0

  func someAsyncFunction(isolation: isolated any Actor) async {
    print("my state is:", state)
  }
}

The non-optional form is safest but loses the #isolation default convenience.

The sending Keyword

Sometimes you need a one-way transfer of a non-Sendable type. The sending keyword promises the value won't be used after transfer:

func process(sending value: NonSendableType) async { }

This enables region-based isolation to prove the transfer is safe even though the type isn't Sendable.

Protocol Conformance Isolation Mismatch

A common problem: protocols require non-isolated methods, but your type is isolated:

protocol NonIsolatedProtocol {
  func someFunction()
}

@MainActor
class MyClass: NonIsolatedProtocol {
  private var state = 0

  // ERROR: Protocol requires non-isolated, type is MainActor
  func someFunction() { }
}

Solution 1: Remove Isolation

If the type doesn't actually need isolation, remove it:

class MyClass: NonIsolatedProtocol {
  private var state = 0

  func someFunction() {
    print("my state is:", state)
  }
}

The type will be created in some isolation domain and stay there. Clients handle isolation.

Solution 2: Use nonisolated

If only some methods need to be non-isolated:

@MainActor
class MyClass: NonIsolatedProtocol {
  private let state = 0  // Must be immutable

  nonisolated func someFunction() {
    print("my state is:", state)
  }
}

Solution 3: Isolated Parameters

For async methods in non-Sendable types:

class MyClass {
  private var state = 0

  func asyncMethod(isolation: isolated (any Actor)? = #isolation) async {
    // Works from any isolation context
  }
}

Approachable Concurrency Reduces Sendable Friction

With Approachable Concurrency enabled:

  • If code doesn't cross isolation boundaries, you don't need Sendable
  • Async functions stay on the caller's actor instead of hopping to background
  • The compiler is smarter about detecting when values are used safely

Sendable errors become much rarer. When you do need parallelism, mark functions @concurrent and then think about Sendable.

Source: SKILL.md on GitHub

1 warning16d5 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    This skill is a technical guide for Swift developers on modern concurrency patterns. It contains educational documentation and code examples. No security risks were identified.

  • Socket16d

    No alerts

  • Snyk16d

    Risk: LOW · No issues

  • Runlayer7mo

    6/6 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at a3824c9. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Dormantupdated 9 months ago

README badge

README badge for jamesrochabrun/skills/swift-concurrency