Making a type Sendable without reaching for @unchecked
When strict concurrency checking arrives in a codebase, the fastest way to make the errors stop is
@unchecked Sendable. I know because I did it to about fifteen types in an afternoon, and every one
of them was a lie I would have to come back to.
@unchecked means “I promise this is thread-safe and I am taking responsibility for it”. If you
cannot say why it is thread-safe in one sentence, you have not made anything safe — you have turned
a compiler error into a data race.
Here is what I should have done instead.
What Sendable actually requires
A Sendable type is one that can cross an isolation boundary safely. The compiler can verify this
automatically in three cases:
- A value type whose stored properties are all
Sendable. Structs and enums get the conformance synthesised. - An actor. Actors are
Sendableby definition — their state is protected. - An immutable final class whose stored properties are all
letand allSendable.
That third one is the underused answer:
final class Configuration: Sendable {
let apiURL: URL
let timeout: TimeInterval
let retries: Int
}
No @unchecked, no lock, and the compiler verifies it. A surprising number of the types I had marked
@unchecked were already immutable — I just had not declared them final, or had one var that
was never actually mutated after initialisation.
The first thing to try is making the type immutable. It is free, the compiler checks it, and it usually improves the design anyway.
Option two: make it a struct
If a class exists only to be passed around and holds no identity, it probably wants to be a struct:
// before: class, needed @unchecked
final class Coordinates {
var latitude: Double
var longitude: Double
}
// after: struct, Sendable for free
struct Coordinates: Sendable {
var latitude: Double
var longitude: Double
}
Value semantics mean there is no sharing, so there is nothing to race on. This is the same argument value types have always had, restated by the concurrency checker.
Option three: make it an actor
If it genuinely has mutable state that several places touch, that is what actors are for:
actor ImageCache {
private var storage: [URL: Image] = [:]
func image(for url: URL) -> Image? { storage[url] }
func store(_ image: Image, for url: URL) { storage[url] = image }
}
The cost is that every access becomes await, which ripples outward through the callers. That
ripple is the honest price of shared mutable state, and it is worth paying when the state really is
shared — but it is also why this should not be the first thing you reach for.
Warning
Do not make a type an actor just to silence a warning. An actor that is only ever touched from one
place has all the await cost and none of the benefit, and it introduces reentrancy — the state
can change across every await, which is a new class of bug you did not have before.
Option four: isolate it to the main actor
For anything that only ever runs on the main thread — view models, UI state, most of a UIKit
codebase — @MainActor is both the correct annotation and a free Sendable conformance:
@MainActor
final class ProfileViewModel {
var name = ""
var isLoading = false
}
The compiler now guarantees this is only touched from the main actor, which is what you were informally relying on anyway. This resolved more of my errors than the other three options combined, because a large fraction of “shared mutable state” in an app is main-thread-only state that was never documented as such.
When @unchecked is legitimate
There is one honest use: a type whose thread safety is real but expressed in a way the compiler cannot see. Usually that means a lock.
final class Counter: @unchecked Sendable {
private let lock = NSLock()
private var _value = 0
var value: Int {
lock.withLock { _value }
}
func increment() {
lock.withLock { _value += 1 }
}
}
That is a correct @unchecked, and the things that make it correct are worth naming: every access to
_value goes through the lock, _value is private so nothing can bypass it, and the class is
final so no subclass can add unprotected state.
If you write @unchecked, write a comment saying which of those invariants holds it together. The
next person — including you — cannot reconstruct it from the code, because the whole point is that
the compiler could not either.
The order I use now
- Can it be immutable? →
letproperties,final class Foo: Sendable. - Does it need identity? If not → make it a
struct. - Is it main-thread-only? →
@MainActor. - Is it genuinely shared mutable state? →
actor. - Is it shared mutable state that cannot be an actor, for a real reason? →
@uncheckedwith a lock and a comment.
Going through those in order took me from fifteen @unchecked conformances to two, and both of the
survivors are lock-based wrappers around C libraries. Which is exactly the population @unchecked
was designed for.