The migration to Swift 6 introduces strict concurrency checking (SWIFT_STRICT_CONCURRENCY=complete). The most pervasive build failure developers encounter during this migration is:
Capture of 'self' with non-sendable type 'MyClass' in a @Sendable closure
This error paralyzes codebases relying on standard patterns like delegation, completion handlers, or capturing self inside a Task. This post analyzes the root cause of this violation and provides the architectural patterns required to resolve it.
The Root Cause: Isolation Domains
In Swift 6, a closure marked @Sendable (like those passed to Task, Task.detached, or SwiftUI modifiers) allows the code inside it to run in a different concurrency domain than where it was created.
If that closure captures a reference type (a class) that is not thread-safe, two threads could mutate that object simultaneously. Swift 6 treats this potential data race as a compiler error, not a warning.
A type is Sendable if it is safe to share concurrently.
- Value types (structs, enums) are
Sendableif their members areSendable. - Actors are implicitly
Sendablebecause they serialize access. - Classes are not
Sendableby default because they have mutable reference semantics.
The Problematic Scenario
Consider a typical networking manager. This code compiles in Swift 5 but fails in Swift 6 because UserProfileManager is a mutable class captured inside a Task.
class UserProfileManager {
var lastUpdated: Date?
var userID: String
init(userID: String) {
self.userID = userID
}
func fetchProfile() {
// ERROR: Capture of 'self' with non-sendable type 'UserProfileManager'
// in a `@Sendable` closure.
Task {
let profile = await apiCall(for: self.userID)
self.lastUpdated = Date() // Mutation happening potentially off-thread
print("Updated: \(profile)")
}
}
func apiCall(for id: String) async -> String {
// Simulate network
try? await Task.sleep(nanoseconds: 1_000_000_000)
return "Profile-\(id)"
}
}
The compiler cannot guarantee that UserProfileManager isn't being mutated on another thread while the Task executes.
Solution 1: Global Actor Isolation (The UI Pattern)
If the class manages state primarily for the UI (like a ViewModel), the correct fix is to isolate the entire type to the @MainActor.
Types annotated with @MainActor are implicitly Sendable. The compiler ensures that all properties and methods are accessed exclusively on the main thread, eliminating data races.
// 1. Isolate the class to the MainActor
@MainActor
class UserProfileViewModel {
var lastUpdated: Date?
var userID: String
init(userID: String) {
self.userID = userID
}
func fetchProfile() {
// No error.
// The Task inherits the @MainActor context because the method is isolated.
Task {
let profile = await apiCall(for: self.userID)
self.lastUpdated = Date()
print("Updated: \(profile)")
}
}
// Explicitly non-isolated helper if it does heavy lifting
nonisolated func apiCall(for id: String) async -> String {
try? await Task.sleep(nanoseconds: 1_000_000_000)
return "Profile-\(id)"
}
}
Why this works
By marking the class @MainActor, references to self can be safely passed around because the compiler enforces that any read/write to self happens on the main thread. The Task { } block inherits the actor context from fetchProfile().
Solution 2: Converting to an Actor (The Service Pattern)
If the class represents a backend service, data store, or logic not tied to the UI, convert it to an actor. Actors protect their internal mutable state through isolation.
// 1. Change 'class' to 'actor'
actor UserDataService {
var lastUpdated: Date?
let userID: String
init(userID: String) {
self.userID = userID
}
func fetchProfile() {
// No error.
// Actors are implicitly Sendable. 'self' is safe to capture.
Task {
let profile = await apiCall(for: self.userID)
// 2. State mutation is safe because we are "inside" the actor
self.lastUpdated = Date()
print("Updated: \(profile)")
}
}
func apiCall(for id: String) async -> String {
try? await Task.sleep(nanoseconds: 1_000_000_000)
return "Profile-\(id)"
}
}
Why this works
Actors allow reentrancy and serialize access. When you capture self in a Task created inside an actor method, that task executes on the actor's executor.
Solution 3: Internal Synchronization (The Library Pattern)
Sometimes you cannot change a class to an actor or bind it to the main thread (e.g., a low-level utility class). To make a reference type Sendable, it must be final and contain only immutable storage, or you must prove to the compiler you are handling locking manually.
Use @unchecked Sendable only if you implement an internal lock (OSAllocatedUnfairLock or NSLock).
import os.lock
// 1. Must be final
final class ThreadSafeCache: @unchecked Sendable {
// 2. Private mutable state
private var cache: [String: String] = [:]
// 3. Low-level lock
private let lock = OSAllocatedUnfairLock()
func save(key: String, value: String) {
lock.withLock {
cache[key] = value
}
}
func performBackgroundWork() {
Task.detached { [weak self] in
// Even though this is detached, 'self' is Sendable
// so this capture is valid.
self?.save(key: "log", value: "started")
}
}
}
Why this works
The @unchecked Sendable attribute disables the compiler's strict checks for this specific type. By doing so, you are assuming full responsibility for thread safety. The OSAllocatedUnfairLock ensures that concurrent access to the cache dictionary does not cause a crash.
Solution 4: Capturing Data, Not Objects (The DTO Pattern)
Often, you don't actually need to capture self. You only need specific data properties to perform an operation. Swift 6 encourages passing value types (structs) across boundaries rather than references.
Refactor the capture list to capture specific, immutable values.
class ImageProcessor {
let configID: String
init(configID: String) {
self.configID = configID
}
func process() {
// Capture value type 'id' instead of reference type 'self'
let id = self.configID
Task.detached {
// This closure no longer captures 'self' (the class).
// It captures 'id' (a String, which is Sendable).
let result = await self.heavyProcessing(id: id)
print(result)
}
}
// Static methods are easier to use in detached tasks
// as they don't depend on instance state.
static func heavyProcessing(id: String) async -> String {
return "Processed image \(id)"
}
}
Summary of Strategy
When you encounter "Capture of non-sendable type":
- Is it UI code? Add
@MainActorto the class. - Is it a service/manager? Change
classtoactor. - Is it a data model? Change
classtostructor make the classfinalwithletproperties. - Is it a low-level utility? Use internal locking and
@unchecked Sendable. - Can you avoid the capture? Capture specific value types
[id = self.id]instead of[self].