Skip to main content

Swift 6 Concurrency: Handling 'Non-Sendable Type' Errors in @MainActor

 If you have recently enabled Swift 6 language mode or turned on "Strict Concurrency Checking" in Xcode, you have likely encountered the build-breaking wall of red errors. The most persistent among them is usually:

"Passing argument of non-sendable type 'X' outside of its actor-isolated context may introduce data races."

This error often appears when you attempt to pass a standard class (like a View Model or a data object) into a Task, or when moving data between background threads and the @MainActor.

In Swift 6, data safety is no longer a suggestion—it is a compiler requirement. This guide breaks down exactly why this error occurs and provides three architectural patterns to resolve it without compromising your app's performance.

The Root Cause: Why "Non-Sendable" Matters

To fix the error, you must understand the "Isolation Boundary."

In the older Grand Central Dispatch (GCD) world, it was the developer's responsibility to ensure that a mutable class instance wasn't modified by two threads simultaneously. If you failed, the app crashed randomly in production.

Swift 6 shifts this responsibility to the compiler.

The Definition of Sendable

The Sendable protocol is a marker. It tells the compiler: "It is safe to copy this value across thread boundaries."

  • Value Types (Structs/Enums): Are implicitly Sendable if all their properties are Sendable. Copies are independent; changing one copy does not affect the other.
  • Reference Types (Classes): Are not Sendable by default. If you pass a class instance to a background task, both the main thread and the background thread hold a reference to the same memory address. This creates a potential data race.

When you see the "Non-Sendable" error, the compiler is refusing to compile code that might allow a race condition.

The Scenario: A Typical Build Failure

Consider a common scenario where a mutable class stores user state. This code works in Swift 5 but fails under Swift 6 strict concurrency.

// A typical mutable class (Reference Type)
class UserProfile {
    var username: String
    var age: Int
    
    init(username: String, age: Int) {
        self.username = username
        self.age = age
    }
}

@MainActor
class ProfileViewModel {
    var user: UserProfile
    
    init(user: UserProfile) {
        self.user = user
    }
    
    func updateProfile() {
        // ERROR: Capture of 'self.user' with non-sendable type 'UserProfile' 
        // in a `@Sendable` closure
        Task.detached {
            print("Saving user: \(self.user.username)") 
            // Network request simulation...
        }
    }
}

The Task.detached creates a new execution context. Passing self.user (a reference type) into that closure crosses an isolation boundary. Since UserProfile is not thread-safe, the compiler blocks it.

Solution 1: Convert to Value Types (The Preferred Fix)

The most robust architectural fix is to favor struct over class for data models. Structs have value semantics. When a struct is passed into a closure, it is copied. The background task gets its own copy, eliminating race conditions.

The Implementation

Change the data model definition from class to struct.

// implicitly Sendable because String and Int are Sendable
struct UserProfile {
    var username: String
    var age: Int
}

@MainActor
class ProfileViewModel {
    var user: UserProfile
    
    init(user: UserProfile) {
        self.user = user
    }
    
    func updateProfile() {
        // Capture a snapshot of the value
        let userSnapshot = self.user
        
        Task.detached {
            // This is now safe. 'userSnapshot' is a distinct copy.
            print("Saving user: \(userSnapshot.username)")
        }
    }
}

Why this works: The compiler sees that UserProfile is a struct containing only Sendable primitives. It automatically conforms to Sendable. Passing it across boundaries is thread-safe because no memory is shared.

Solution 2: Actor Isolation (The "View Model" Fix)

Sometimes you cannot use a struct. You might be dealing with a reference that needs to be shared, or an existing View Model that is too large to refactor entirely.

In this case, you must ensure the class lives entirely within a specific isolation context—usually the @MainActor for UI-related logic.

The Implementation

If the class is annotated with @MainActor, all its properties and methods are isolated to the main thread. To access them from a background task, you must await them, ensuring thread safety.

However, typically you want to pass data out of the actor. To do this, do not pass the whole class. Pass the specific data needed.

// This class remains non-Sendable
class UserManager {
    var username: String = "Guest"
}

@MainActor
class DashboardViewModel {
    let manager = UserManager()
    
    func performBackgroundWork() {
        // 1. Extract the specific primitive data needed (Strings are Sendable)
        let nameToProcess = manager.username
        
        Task {
            // 2. Perform work off the main actor using the extracted data
            let processedName = await processName(nameToProcess)
            
            // 3. Update state back on the MainActor
            self.manager.username = processedName
        }
    }
    
    // This function runs on a generic background thread
    nonisolated func processName(_ name: String) async -> String {
        // Simulate heavy work
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        return name.uppercased()
    }
}

Why this works: We are not passing the UserManager class into the background. We are extracting the String (which is Sendable) and passing that. The UserManager reference never leaves the @MainActor.

Solution 3: Checked Sendable with Internal Locking

If you absolutely must pass a reference type across threads (e.g., a shared cache or a logging service), you must make the class conform to Sendable.

Because classes are mutable, the compiler cannot verify safety automatically. You must use final, conform to Sendable, and use internal locking to prove safety.

The Implementation

We use OSAllocatedUnfairLock (available in newer iOS versions) or NSLock to synchronize access.

import os.lock

// 1. Class must be final to conform to Sendable
final class ThreadSafeLogger: Sendable {
    // 2. State must be private
    private let state = OSAllocatedUnfairLock(initialState: [String]())
    
    func addLog(_ message: String) {
        // 3. All mutations happen inside the lock
        state.withLock { logs in
            logs.append(message)
        }
    }
    
    func getLogs() -> [String] {
        state.withLock { logs in
            // Return a copy of the logs
            return logs
        }
    }
}

@MainActor
class AppController {
    let logger = ThreadSafeLogger()
    
    func run() {
        // Since ThreadSafeLogger is Sendable, we can capture it directly
        Task.detached {
            self.logger.addLog("Background task started")
        }
    }
}

Why this works:

  1. final: Prevents subclassing, ensuring behavior doesn't change.
  2. Sendable: We explicitly tell the compiler we handled the threading.
  3. Locking: We ensure that even if two threads call addLog instantly, the underlying array is accessed serially.

Edge Case: @unchecked Sendable

You will encounter the @unchecked Sendable attribute in documentation.

class LegacyDatabase: @unchecked Sendable { ... }

Do not use this unless absolutely necessary.

Using @unchecked tells the compiler: "Turn off safety checks for this class; I promise I wrote thread-safe C-code inside." If you use this on a standard Swift class with var properties and no locks, you are re-introducing the random crashes Swift 6 tries to prevent. Only use this if you are wrapping a C-pointer or an inherently thread-safe system framework that hasn't been updated yet.

Conclusion

The "Non-Sendable" error is the most common friction point in the Swift 6 migration, but it pushes code toward better architecture.

  1. Default to Structs: If it's just data, use a value type.
  2. Isolate Scope: If it's a UI object, keep it on @MainActor and only pass primitives to background tasks.
  3. Synchronize Internally: If a class must be shared, lock its state and mark it final class ... : Sendable.

By respecting the isolation boundary rather than fighting the compiler, you ensure your iOS application is free of data races and ready for the future of Swift.