@unchecked Sendable and Thread Safety

Sometimes you need a reference type to be Sendable, but the compiler can't verify it. That's where @unchecked Sendable comes in.
Follow along with the code: iOS-Practice on GitHub
What @unchecked Means
You're telling the compiler: "Trust me, I've made this thread-safe manually."
final class ThreadSafeCounter: @unchecked Sendable {
private var _count = 0
private let lock = NSLock()
var count: Int {
lock.lock()
defer { lock.unlock() }
return _count
}
func increment() {
lock.lock()
defer { lock.unlock() }
_count += 1
}
}
The compiler allows this across actor boundaries because YOU promised it's safe.
When to Use @unchecked Sendable
1. Lock-based synchronization:
final class SynchronizedArray<T>: @unchecked Sendable {
private var array: [T] = []
private let lock = NSLock()
func append(_ item: T) {
lock.lock()
defer { lock.unlock() }
array.append(item)
}
func getAll() -> [T] {
lock.lock()
defer { lock.unlock() }
return array
}
}
2. Dispatch queue synchronization:
final class QueueSafeStorage: @unchecked Sendable {
private var data: [String: Any] = [:]
private let queue = DispatchQueue(label: "storage", attributes: .concurrent)
func read(_ key: String) -> Any? {
queue.sync { data[key] }
}
func write(_ key: String, value: Any) {
queue.async(flags: .barrier) { self.data[key] = value }
}
}
3. Wrapping non-Sendable system types:
final class SendableDateFormatter: @unchecked Sendable {
private let formatter: DateFormatter
private let lock = NSLock()
init(format: String) {
formatter = DateFormatter()
formatter.dateFormat = format
}
func string(from date: Date) -> String {
lock.lock()
defer { lock.unlock() }
return formatter.string(from: date)
}
}
The Dangers
@unchecked disables compiler checks. If you're wrong, you get data races:
// DANGEROUS: No actual synchronization!
final class BrokenCache: @unchecked Sendable {
private var cache: [String: Data] = [:] // No lock!
func get(_ key: String) -> Data? {
cache[key] // Race condition!
}
func set(_ key: String, data: Data) {
cache[key] = data // Race condition!
}
}
This will compile but crash randomly at runtime.
Alternatives to @unchecked
Use an actor instead:
actor SafeCache {
private var cache: [String: Data] = [:]
func get(_ key: String) -> Data? {
cache[key]
}
func set(_ key: String, data: Data) {
cache[key] = data
}
}
Actors provide synchronization automatically—safer than manual locks.
Use a Sendable struct:
struct CacheEntry: Sendable {
let key: String
let data: Data
let timestamp: Date
}
Value types copy, eliminating shared mutable state.
Auditing @unchecked Sendable
When reviewing code, treat @unchecked Sendable as a red flag:
- Verify ALL mutable state is protected
- Check that ALL access paths use synchronization
- Consider if an actor would be simpler
Interview Tip
Show you understand the trade-off: "@unchecked Sendable is an escape hatch when you need manual synchronization, but actors are usually safer. I only use @unchecked for legacy code or when wrapping thread-unsafe system types."