The transition to Swift 6 is the most significant shift in the language's history since the introduction of SwiftUI. If you have recently set SWIFT_STRICT_CONCURRENCY to complete in your build settings, you likely woke up to a "sea of red"—hundreds, perhaps thousands, of compile-time errors flagging data races and actor isolation violations.
This is not a linter becoming overly aggressive; it is a fundamental change in how Swift guarantees memory safety. The compiler is no longer assuming your code is safe—it now demands proof.
This guide analyzes the root causes of "Sendable" and Actor isolation errors and provides architectural patterns to resolve them without compromising performance or resorting to unsafe workarounds.
The Root Cause: From Implicit to Explicit Safety
In Swift 5, concurrency was often handled via documentation or convention. You might comment // ensure this is called on the main thread, but the compiler had no way to enforce it.
Swift 6 introduces Data Isolation as a first-class language feature. The compiler now tracks the "domain" (or isolation context) of every variable and function.
The Sendable Protocol
The crux of most strict concurrency errors is the Sendable protocol. A type is Sendable if its values can safely be passed across concurrency domains (e.g., from a background Task to the MainActor).
- Value types (Structs/Enums): Implicitly
Sendableif their properties areSendable. - Reference types (Classes): Not
Sendableby default because two threads can hold a reference to the same mutable object, leading to race conditions. - Closures: Not
Sendableby default unless annotated, as they might capture mutable state from their creation context.
When you see Type 'MyClass' does not conform to protocol 'Sendable', the compiler is preventing a potential race condition before it happens.
Scenario 1: Fixing "Non-Sendable" Class Errors
The most common error occurs when passing a standard reference type into a Task or between actors.
The Problem
You have a model class holding mutable state.
class UserProfile {
var username: String
var lastActive: Date
init(username: String) {
self.username = username
self.lastActive = Date()
}
}
func updateProfile(_ profile: UserProfile) async {
// ERROR: Passing argument of non-sendable type 'UserProfile'
// outside of its actor-isolated context
await API.send(profile)
}
Solution A: Convert to Value Semantics (Preferred)
If the identity of the object doesn't matter (i.e., you don't need to share the exact instance pointer), convert it to a struct. Structs with standard properties are implicitly Sendable.
struct UserProfile: Sendable {
var username: String
var lastActive: Date
}
// This now compiles without warnings.
Solution B: Final Classes with Immutable State
If you must use a class (perhaps for Objective-C interoperability or specific library requirements), you can make it Sendable if it is immutable.
final class UserProfile: Sendable {
let username: String
let lastActive: Date // Must be 'let'
init(username: String) {
self.username = username
self.lastActive = Date()
}
}
Note: The class must be final to prevent subclassing, which could introduce mutable state.
Solution C: Actor Isolation
If you need shared mutable state, the Swift 6 answer is an actor. Actors serialize access to their state.
actor UserProfileManager {
private var activeProfiles: [String: UserProfile] = [:]
func update(_ profile: UserProfile) {
activeProfiles[profile.username] = profile
}
func getProfile(id: String) -> UserProfile? {
return activeProfiles[id]
}
}
// Usage
let manager = UserProfileManager()
// Must use 'await' to enter the actor's isolation domain
await manager.update(myProfile)
Scenario 2: Handling Legacy Singletons
Many codebases rely on singletons that are accessed from arbitrary threads. In Swift 6, global variables are restricted because they are unsafe shared state.
The Problem
class NetworkLogger {
static let shared = NetworkLogger()
var logs: [String] = [] // Mutable state accessed globally
func log(_ message: String) {
logs.append(message)
}
}
// ERROR: Static property 'shared' is not concurrency-safe because it is not either
// conforming to 'Sendable' or isolated to a global actor.
The Fix: Global Actor Isolation
Usually, singletons interact heavily with the UI or coordinate application state. The easiest fix is often isolating the singleton to the @MainActor. This ensures all access happens on the main thread, eliminating data races.
@MainActor
class NetworkLogger {
static let shared = NetworkLogger()
var logs: [String] = []
func log(_ message: String) {
logs.append(message)
}
}
// Usage in background code:
func performBackgroundWork() async {
let result = calculatePrimes()
// Must await the call to jump to the MainActor
await NetworkLogger.shared.log("Calculation complete: \(result)")
}
If the singleton must perform heavy work and should not block the main thread, convert it to an actor.
Scenario 3: Escaping Closures and Delegates
Delegates and callbacks are frequent sources of "Capture of non-sendable type" errors.
The Problem
class DataFetcher {
// ERROR: parameter 'completion' is implicitly non-sendable
func fetch(completion: @escaping (Result<String, Error>) -> Void) {
Task {
let data = await expensiveNetworkCall()
completion(.success(data))
}
}
}
When Task executes, it might run on a background thread. If the completion closure captures mutable state from the caller (which might be on the Main Actor), executing it from the background is a race condition.
The Fix: @Sendable Annotation
You must explicitly tell the compiler that the closure is safe to pass between concurrency domains.
class DataFetcher {
// 1. Add @Sendable to the closure definition
func fetch(completion: @escaping @Sendable (Result<String, Error>) -> Void) {
Task {
let data = await expensiveNetworkCall()
completion(.success(data))
}
}
}
However, adding @Sendable imposes restrictions on the closure: it cannot capture mutable local variables, and everything it captures must be Sendable.
Managing "Context" in Closures
If you are calling this from a View Controller, you might get an error because self (the UIViewController) is not Sendable.
// Inside a UIViewController
func refreshData() {
fetcher.fetch { [weak self] result in
// ERROR: Capture of 'self' with non-sendable type 'MyViewController'
self?.handle(result)
}
}
To fix this, we rely on the fact that UIViewController is bound to the @MainActor. We need to ensure the closure is executed in the correct isolation context.
func refreshData() {
// Ensure the fetcher understands where to call back,
// OR allow the closure to jump back to the actor manually.
fetcher.fetch { [weak self] result in
// Valid approach: Recapture MainActor isolation
await MainActor.run {
self?.handle(result)
}
}
}
Deep Dive: @unchecked Sendable (The Nuclear Option)
Sometimes, you have a class that is thread-safe (perhaps using an internal DispatchQueue or NSLock), but the compiler cannot verify it because it can't see inside your implementation or C++ dependencies.
In this case, you use @unchecked Sendable.
import Foundation
// The compiler cannot verify the lock makes this safe, so we promise it is.
final class ThreadSafeCache: @unchecked Sendable {
private var storage: [String: Any] = [:]
private let lock = NSLock()
func set(_ value: Any, forKey key: String) {
lock.lock()
defer { lock.unlock() }
storage[key] = value
}
}
Warning: Use this sparingly. You are disabling compiler safety checks. If you forget to lock even one property access, you introduce a data race that Swift 6 would have otherwise caught.
Handling 3rd Party Dependencies: @preconcurrency
A major pain point is importing libraries that haven't updated to Swift 6 strict concurrency yet. You will see errors claiming types from that library aren't Sendable.
You cannot edit their code. Instead, use @preconcurrency import.
@preconcurrency import OldObjectiveCLibrary
struct MyWrapper {
// The compiler will downgrade errors to warnings or suppress them
// for types coming from this specific import.
let legacyObject: OldLibraryObject
}
This tells Swift: "I know this library doesn't strictly conform to Sendable, but assume it does for now so I can build." This allows you to migrate your code without waiting for the entire ecosystem to catch up.
Conclusion
Swift 6 Strict Concurrency is not about sprinkling await keywords randomly until code compiles. It requires a mental shift regarding memory ownership.
- Prefer Value Types: Structs are your best defense against concurrency complexity.
- Isolate State: Use
actorfor shared mutable state or@MainActorfor UI state. - Annotate Boundaries: Use
@Sendablefor closures crossing actor boundaries. - Retrofit carefully: Use
@unchecked Sendableonly for internally synchronized classes and@preconcurrency importfor legacy dependencies.
By addressing the root architecture rather than suppressing warnings, you ensure your app is prepared for the future of the Apple ecosystem.