@Sendable, sending and unchecked sendable. Region-Based Isolation
A guide to region-based isolation, @Sendable, sending and unchecked sendable, and closure safety in Swift 6 with examples.
Changes in Task, @Sendable, and Region-Based Isolation in Swift 6
🔍 Error in Swift 5
In Swift 5, you may have tried code like this:
swiftfunc runTheExample() async throws { let user = User() Task { try await sendAnalyticsEvents(for: user) } }
Looks harmless, right? But in Swift 5, the compiler would complain:
Capture of 'user' with non-sendable type 'User' in a @Sendable closure
Why?
Because Task in Swift 5 took an @Sendable closure by default. If you captured a non-Sendable type (like most class instances), you'd hit a wall. You had to explicitly mark types as Sendable, or refactor to avoid the capture.
swiftvar closure: @Sendable () async throws -> Void = { print(user.name) // ❌ Compilation error }
✅ What Changed in Swift 6
Swift 6 introduces region-based isolation. This change allows Task to safely capture non-Sendable values as long as the compiler can verify they don’t escape the closure.
Now the same code compiles:
swiftfunc runTheExample() async throws { let user = User() Task { try await sendAnalyticsEvents(for: user) // ✅ Works in Swift 6! } }
Swift understands that user only lives within the closure's lifetime. It doesn't leak across concurrency boundaries.
This is part of a broader movement in Swift Concurrency toward pragmatic safety.
📦 Sendable objects in Swift
- It's safe to use concurrently
- It can cross isolation boundaries
- The compiler enforces these guarantees
✅ ✅ Actors are always Sendable
You can safely send actors between concurrency regions.
✅ Structs are Sendable if:
- All their members are Sendable
- They explicitly conform to Sendable (for public types)
🧱 Examples of Sendable Types
✅ A Sendable Struct
swiftstruct SendableStruct { let id = UUID() var count = 0 }
❌ A Non-Sendable Struct
swiftclass User {} struct NonSendableStruct { let id = UUID() let user = User() // User is a class and not Sendable }
✅ A Sendable Enum
swiftenum SendableEnum: Sendable { case one, two, three case user(SendableStruct) }
❌ A Non-Sendable Enum
swiftenum NonSendableEnum: Sendable { case one, two, three case user(User) // User is not Sendable }
An enum is Sendable if:
- It has no associated values
- Or all associated values are Sendable
- And it conforms to Sendable
✅ A Sendable Class
swiftfinal class SendableClass: Sendable { let id = UUID() let count = 0 }
❌ A Non-Sendable Class
swiftfinal class NonSendableClass: Sendable { let id = UUID() let user = User() // User is not Sendable }
A class is Sendable if:
- All members are
Sendable - It has no mutable state
- It's a
finalclass - It conforms to
Sendable
⚠️ Using @unchecked Sendable
There are cases where your type logically should be Sendable, but the compiler can’t verify it. In such cases, you can mark it as @unchecked Sendable manually.
It makes sense to use @unchecked if:
- You know that your class is thread-safe (and have tests to prove it)
- You intend to revisit and adjust your class at a later time
- You use it only temporarily
This is useful during migrations and when working with legacy code, but be cautious — the compiler won’t protect you here.
🧵 Understanding @Sendable Closures
An @Sendable closure:
- Should be safe to run from any isolation context, concurrently
- Can only capture
Sendablestate
Defining and Using
swiftvar myClosure: @Sendable () async throws -> Void = { // ... } func runCode(_ closure: @Sendable () -> Void) { // ... }
But here's the catch:
An @Sendable closure is probably not what you want if you're capturing non-Sendable types inside it.
Example:
swiftfunc passUserExample() async throws { let user = User() runSendableExample { print(user.name) // ❌ if closure is @Sendable } } func runSendableExample(_ closure: @Sendable () -> Void) { closure() }
This is technically safe, but won't compile unless you use Swift 6's sending modifier:
swiftfunc runSendableExample(_ closure: sending () -> Void) { closure() }
✅ In Swift 6, sending allows capturing non-Sendable types safely under compiler-verified constraints.
📬 Swift 6 Introduces sending
In Swift 6, closures can now be marked with sending, enabling safe capture of non-Sendable values when the compiler determines it's safe.
swiftfunc runSendableExample(_ closure: sending () -> Void) { closure() }
This allows:
swiftfunc passUserExample() async throws { let user = User() runSendableExample { print(user.name) // ✅ Compiles in Swift 6 } }
🧠 What's different?
@Sendableclosures: strict and isolated — onlySendablevalues allowedsendingclosures: compiler-analyzed and region-aware
🔒 Sending and Safety
Consider this:
swiftfunc passUserExample() async throws { let user = User() // First access: sending closure runSendableExample { print(user.name) } // Second access after sending print(user.name) } func runSendableExample(_ closure: sending @escaping () -> Void) { closure() }
This can race, because after user is "sent" to the closure, accessing it again concurrently can lead to issues.
⚠️ Rule of thumb: avoid using values after sending them.
⚠️ Summary: Swift 5 vs Swift 6
| Feature | Swift 5 | Swift 6 |
|---|---|---|
Task closure | Always @Sendable | Still @Sendable, but supports region-based analysis |
Non-Sendable capture | Compiler error | Allowed if confined to region |
| Actor isolation | Manual coordination | Built-in via compiler |
| Type checks | Strict | Context-sensitive |
| Closure isolation | Rigid | Flexible with sending |