Skip to main content

Fixing 'Capture of Non-Sendable Type' Errors in Swift 6 Concurrency

 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 TaskTask.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.

  1. Value types (structs, enums) are Sendable if their members are Sendable.
  2. Actors are implicitly Sendable because they serialize access.
  3. Classes are not Sendable by 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":

  1. Is it UI code? Add @MainActor to the class.
  2. Is it a service/manager? Change class to actor.
  3. Is it a data model? Change class to struct or make the class final with let properties.
  4. Is it a low-level utility? Use internal locking and @unchecked Sendable.
  5. Can you avoid the capture? Capture specific value types [id = self.id] instead of [self].