Upgrading a codebase to Swift 6 often feels like hitting a wall. You flip the switch to strict concurrency checking, and suddenly, a project that compiled perfectly is flooded with errors.
"Type 'X' does not conform to 'Sendable'." "Capture of non-sendable type in @Sendable closure." "Main actor-isolated property 'y' can not be referenced from a non-isolated context."
These are not just linting annoyances; they are fundamentally changing how Swift handles memory safety. Swift 6 enforces complete concurrency safety at compile time. This guide breaks down the root causes of these errors and provides architectural patterns to resolve them without suppressing valid warnings.
The Root Cause: Isolation Boundaries
To fix these errors, you must understand the underlying model. Swift 6 views your memory as a series of Isolation Domains.
- The Main Actor: The UI thread.
- Actors: Custom isolated buckets of mutable state.
- The Sea of Concurrency: Unstructured Tasks and generic background threads.
A Data Race occurs when two different threads access the same memory location, and at least one of them is writing to it. Before Swift 6, ensuring thread safety was the developer's responsibility (using locks or dispatch queues). If you forgot a lock, the compiler said nothing, and the app crashed in production.
In Swift 6, the compiler assumes everything is unsafe unless proven otherwise. When you try to pass an object from one domain (e.g., the Main Actor) to another (e.g., a background Task), the compiler demands proof that the object is safe to share.
That proof is the Sendable protocol.
Strategy 1: The Value Type Conversion
The easiest way to silence "non-Sendable" errors is to stop sharing references entirely. Reference types (classes) are the primary source of concurrency bugs because multiple threads hold pointers to the same mutable memory.
Value types (struct, enum) are implicitly Sendable if their contents are Sendable. They are copied on assignment, meaning each thread gets its own independent copy.
The Problematic Code (Class-based)
// ERROR: Class 'UserProfile' does not conform to 'Sendable'
class UserProfile {
var username: String
var bio: String
init(username: String, bio: String) {
self.username = username
self.bio = bio
}
}
func updateProfileInBackground(profile: UserProfile) async {
// Compile Error: Passing non-sendable parameter 'profile'
// to implicit @Sendable closure
await Task {
print("Processing \(profile.username)")
}
}
The Fix: Convert to Struct
If the object is purely a data carrier (DTO), convert it to a struct.
struct UserProfile: Sendable {
let username: String
let bio: String // Strings are value types, so they are Sendable
}
func updateProfileInBackground(profile: UserProfile) async {
// Valid: 'profile' is copied into the Task closure.
// No shared mutable state exists.
await Task {
print("Processing \(profile.username)")
}
}
Strategy 2: Actor Isolation
If you need shared mutable state—for example, a UserManager that updates login status across the app—you cannot use a struct. You must synchronize access.
In the past, you might have used DispatchQueue or NSLock. In Swift 6, you should convert these classes to Actors. An actor automatically serializes access to its mutable state, making it implicitly Sendable.
The Problematic Code (Unprotected State)
class UserManager {
var activeUser: String?
func login(user: String) {
self.activeUser = user
}
}
// Compiler Error: UserManager is not Sendable, but is likely accessed
// from multiple Tasks.
The Fix: Actor Migration
actor UserManager {
private var activeUser: String?
func login(user: String) {
self.activeUser = user
}
func getCurrentUser() -> String? {
return activeUser
}
}
// Usage
let manager = UserManager()
Task {
// You must 'await' access to actor methods
await manager.login(user: "Alice")
if let user = await manager.getCurrentUser() {
print("User logged in: \(user)")
}
}
This resolves the error because the compiler knows the actor handles its own locking mechanism.
Strategy 3: @unchecked Sendable and Internal Synchronization
Sometimes, you cannot convert a class to an actor (e.g., you need to subclass NSObject or integrate with a legacy delegate pattern). Or, you might be building a highly performant cache where await overhead is unacceptable.
In this case, you can claim conformance to Sendable, but the compiler will require you to use @unchecked Sendable. This tells the compiler: "Trust me, I have implemented manual thread safety."
Warning: If you use @unchecked without actually implementing locks, you will create hard-to-debug crashes.
The Fix: OSAllocatedUnfairLock
Use OSAllocatedUnfairLock (available in modern Swift runtimes) for high-performance, internal synchronization.
import os.lock
// We mark it @unchecked because the compiler cannot verify the lock logic
final class ThreadSafeCache<Key: Hashable, Value>: @unchecked Sendable {
private let lock = OSAllocatedUnfairLock()
private var storage: [Key: Value] = [:]
func insert(_ value: Value, for key: Key) {
lock.withLock {
storage[key] = value
}
}
func value(for key: Key) -> Value? {
lock.withLock {
storage[key]
}
}
}
Strategy 4: Handling Closures and Delegates
A common friction point in Swift 6 is passing functions. If a closure is executed concurrently (like a completion handler in a background task), it must be marked @Sendable.
If that closure captures self, and self is not thread-safe, the compiler will error.
The Problem
class DataFetcher {
var requestCount = 0
func fetchData(completion: @escaping () -> Void) {
DispatchQueue.global().async {
// ERROR: Capture of 'self' with non-sendable type 'DataFetcher'
// in a `@Sendable` closure
self.requestCount += 1
completion()
}
}
}
The Fix: Capture Lists or Isolation
You have two options depending on intent: capture a specific value, or isolate the class.
Option A: Isolate to MainActor If this class updates UI or handles logic that should happen on the main thread, enforce it.
@MainActor
class DataFetcher {
var requestCount = 0
func fetchData() async {
// Since we are on @MainActor, this is safe
self.requestCount += 1
}
}
Option B: Capture List for Value Types If you only need data from the class, capture the value, not the reference.
func process(data: [String]) {
Task { [data] in // Capture immutable copy
// Safe to use 'data' here
print(data)
}
}
Deep Dive: Region-Based Isolation (Swift 6 Exclusive)
Swift 5.10 was notoriously strict. It would flag an error even if you passed a non-Sendable object between actors, even if the first actor never used it again.
Swift 6 introduces Region-Based Isolation. The compiler creates a control-flow graph to prove ownership transfer.
If you create a non-Sendable class instance and immediately pass it to an actor or task without keeping a copy, Swift 6 allows it.
class NonSendableDocument {
var content: String = ""
}
actor Database {
func save(_ doc: NonSendableDocument) {
print("Saving \(doc.content)")
}
}
func createAndSave() async {
let doc = NonSendableDocument()
doc.content = "Quarterly Report"
let db = Database()
// In Swift 5.10: ERROR (NonSendableDocument is not Sendable)
// In Swift 6: OK (Compiler proves 'doc' is not used after this line)
await db.save(doc)
}
The sending Keyword
If the compiler cannot automatically deduce that you gave up ownership, you can force it using the sending keyword (formerly transferring).
func transferOwnership(_ doc: sending NonSendableDocument) async {
// Logic here
}
This signature tells the caller: "When you pass this argument, you lose access to it. It now belongs to this function's isolation domain."
Addressing Legacy Code: @preconcurrency
You will inevitably import libraries that haven't been updated for Swift 6 yet. They won't have Sendable annotations, causing cascading errors in your code.
Do not rewrite their library. Use @preconcurrency.
@preconcurrency import OldObjectiveCLibrary
// The compiler will downgrade errors related to types from this
// library to warnings, or suppress them until the library is updated.
Similarly, if you are maintaining a protocol that legacy clients implement, you can mark the protocol requirement as @preconcurrency to avoid breaking source compatibility for downstream users.
Conclusion
The transition to Swift 6 strict concurrency is not just about silencing warnings; it is about architectural honesty. The "Sendable" errors are revealing race conditions that likely already existed in your application.
- Prefer Value Types (Structs) for data transfer.
- Use Actors for shared mutable state.
- Use
@unchecked Sendablewith locks only as a last resort for performance. - Leverage Region-Based Isolation by ensuring you don't retain references to objects passed to other threads.
By aligning with these patterns, you ensure your app is future-proof, stable, and ready for the parallel processing demands of modern Apple silicon.