Home

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 Sendable by definition — their state is protected.
  • An immutable final class whose stored properties are all let and all Sendable.

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

  1. Can it be immutable? → let properties, final class Foo: Sendable.
  2. Does it need identity? If not → make it a struct.
  3. Is it main-thread-only? → @MainActor.
  4. Is it genuinely shared mutable state? → actor.
  5. Is it shared mutable state that cannot be an actor, for a real reason? → @unchecked with 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.