Swift & Objective-C

[Advanced Swift #3] Migrating Sendable and Swift 6 concurrency errors

Turning on Swift 6 mode unleashes concurrency errors, with Sendable at the center. This guide explains whether values may cross isolation boundaries and presents migration fixes in order: make them structs, make them immutable, or promote them to actors.

6 min read
Cover image for [Advanced Swift #3] Migrating Sendable and Swift 6 concurrency errors

If your team has enabled Swift 6 mode, you probably remember the moment: dozens or hundreds of concurrency errors suddenly appeared in a project that had been running fine.

Most of those errors have one protagonist: Sendable.

Part 3 of the Concurrency series organizes this final puzzle piece and Swift 6 strict concurrency.

The question Sendable asks — May this value cross the boundary?

In the actor installment, we described isolation as a “serial execution context”: inside an actor, on MainActor, or in a cooperative pool belonging to neither.

Yet values cross these boundaries constantly. They enter actor methods as arguments, leave as return values, and get captured by Task closures.

That is where the risk appears. Isolation protects the actor’s own state, but what if a value crossing the boundary is a mutable reference type?

The same instance can then be held by two isolated contexts at once, resurrecting the data race that the actor prevented through the imported object. The border inspection is thorough, but the goods entering the country are not checked.

Sendable defines that inspection standard. It is a marker protocol with no required methods, and its meaning is simple:

Values of this type are safe to use concurrently after crossing an isolation boundary.

Which types are safe? The intuition is straightforward.

Value types are copied when they cross, so they are safe if all stored properties are Sendable. Int, String, and structs and enums made only of Sendable properties belong here. The compiler usually recognizes them automatically.

Actors are safe too, because self-protection is built in.

Immutable classes (final with only let properties) are safe as well. If nothing can change, there is no race.

Exactly one thing is dangerous: classes with mutable state. The formula from the value-type-first installment, “shared + mutable = dangerous,” is Sendable’s actual decision rule.

Function types have a separate @Sendable annotation. The closure in Task { } is a typical @Sendable closure, and a closure with this annotation cannot capture non-Sendable values.

Captures, as we saw in the closure installment, can become smuggling routes across isolation boundaries, so the language checks the route itself.

strict concurrency — Turning warnings into errors, discipline into checks

These rules existed before Swift 6, but they were quiet by default.

The core of Swift 6 language mode is enabling all these checks and promoting violations to errors: strict concurrency. Under complete checking, every point where a non-Sendable value crosses an isolation boundary becomes a compile error.

The errors do not appear because the code suddenly became worse. Previously hidden potential races are simply visible now.

It is like the introduction of optionals exposing every place where a value might be nil. Just as nil checks did then, concurrency assumptions are now moving into the type system.

Fortunately, the compiler is getting smarter too. Swift 6 includes region-based isolation, proposed in Swift Evolution proposal SE-0414.

Even non-Sendable values may be moved when it can prove that the sender will not touch them again.

A value whose ownership has moved cannot create a race. As a result, much code that should theoretically fail actually passes.

The sending parameter annotation follows the same direction. In short, the rule is becoming more precise: from “always forbidden” to “allowed when safety is proven.”

A validation diagram: struct, actor, and final-let classes pass; mutable classes are rejected
Only classes with mutable state are dangerous

Migration in practice — Remedies by error type

Most of the flood of errors converges on a few patterns. Here are the standard remedies by type.

Type 1. My model is non-Sendable. This is the most common and healthiest error. The first remedy is to convert it to a struct.

If a data model that does not need reference identity was declared as a class, this is your opportunity to move it to a value type.

If it must remain a class, make it immutable with final + let and adopt Sendable. If it must be mutable, that means the state needs an owner, so consider promoting it to an actor.

Type 2. Global variables and static var. Every place previously warned about as “static var is effectively global state” becomes an error.

If you truly need global state, declare its isolation. Add @MainActor for UI-related state; otherwise wrap it in an actor or make it immutable with let.

Type 3. A delegate or callback class is caught at the boundary. This often appears where you meet UIKit-era APIs.

If the type is effectively main-thread-only, declaring @MainActor is usually the answer. It turns the implicit fact that “this class was always used on the main thread” into an explicit declaration.

Type 4. It is truly safe, but the compiler does not know. Examples include classes protected internally by locks and C library wrappers.

The escape hatch is @unchecked Sendable. It declares, “I guarantee this is safe, so disable the check,” but unchecked is an unsafe-family tool, as its name warns.

Following the explicit-escape-hatch principle from Philosophy Part 1, document the basis for the guarantee—what each lock protects—and use it within the smallest possible scope.

If you start covering errors with unchecked for migration convenience, you end up with Swift 6 badges on code whose checks are disabled.

Strategically, you do not have to enable everything at once. Language mode can be selected per module.

The standard approach is bottom-up: move leaf modules with few dependencies, such as utilities and models, to Swift 6 mode first, and upgrade the app target last.

Xcode’s upcoming feature flags are also useful for raising only the checking level in advance and observing the warnings.

Reading the direction — Why make us go through this?

Because the pain of migration is real, it is worth understanding exactly what justifies the cost.

Swift 6’s promise is simple: if it compiles, there are no data races. Entire categories of irreproducible intermittent crashes and timing bugs that appear only after release disappear at compile time.

It follows the path memory safety took (optionals, ARC — Automatic Reference Counting). Concurrency safety is moving from “write it well” to “guaranteed by the language.”

This direction also extends the trajectory from the philosophy series: promote error-prone rules into the type system, require explicit annotations where they cost something (@unchecked, sending), and absorb transitional friction through gradual adoption (module-level language modes).

The procedures discussed in the Swift Evolution installment continue to reduce friction by adding buffer mechanisms such as default isolation options. The warnings you see now are the midpoint of that transition.

Illustration of migration signposts from Swift 5 to a healthy Swift 6 state
struct conversion → immutability → actor → MainActor; use unchecked last, with justification

Summary

  • Sendable marks a type whose values are safe to use concurrently across isolation boundaries. Value types, actors, and immutable classes are safe; mutable classes are the entire danger zone.
  • @Sendable closures check their captures and prevent closures from becoming smuggling routes for races.
  • Swift 6 strict concurrency promotes these checks to errors. The error bomb does not mean the code got worse; it means potential races have surfaced.
  • Remedy priority: convert to a struct → make immutable → promote to an actor → declare isolation (@MainActor) → finally, use @unchecked Sendable with its justification documented.
  • Migrate bottom-up from leaf modules, enabling language mode one module at a time.

The next installment concludes the Concurrency series with structured concurrency. It covers the task tree formed by Task, async let, and TaskGroup, and how cancellation propagates through that tree.


References

Continue reading