Struct vs Class in Swift 6: Concurrency-Safe Choices
In Swift 6, the compiler asks you a question you used to answer yourself: who can touch this data?
For years, struct vs class was a style debate. The fundamentals — value semantics, copy-on-write, mutating, identity — are covered in depth in Struct vs Class in Swift: Making the Right Choice, so I won’t re-litigate them here. This post answers the question Swift 6 makes unavoidable: given strict concurrency checking, which choice will the compiler actually let you ship?
That’s the change: in Swift 5 mode, choosing a class when a struct would do was a code smell. In Swift 6 language mode, it’s a compile error — the moment the instance tries to cross a concurrency boundary.
Everything below compiles (or fails to compile, deliberately) in Swift 6 language mode — available since Xcode 16.0 (September 2024). New projects in Xcode 26 still default to Swift 5 mode, but with Approachable Concurrency and default MainActor isolation switched on. Most examples run fine on iOS 17+; the @Observable example needs iOS 17, and I’ll flag the few places where the latest toolchain matters.
1. What Swift 6 Language Mode Actually Changes
Strict concurrency checking existed before Swift 6 — Xcode 15.3 (Swift 5.10) shipped three levels (Minimal, Targeted, Complete), and most projects never turned Complete on, because warnings are easy to ignore and deadlines are real.
Swift 6 language mode removes the choice: complete checking is the only setting, and warnings become errors. The struct-vs-class decision stops being a style guideline and becomes a contract the compiler enforces.
Here’s the canonical example — the one every team hits during migration:
// Swift 6 language mode — this fails to compile:
final class Counter {
var value = 0
}
func startCounting() {
let counter = Counter()
Task {
counter.value += 1
// Error (wording varies slightly by toolchain):
// capture of 'counter' with non-Sendable type 'Counter'
// in a @Sendable closure
}
print(counter.value)
}
The class is captured by a concurrent task. The compiler can’t prove the task’s mutation won’t race with the print on the current thread, so it refuses. Now the same intent with a struct:
struct Counter {
var value = 0
}
func snapshotCount() async -> Int {
let counter = Counter(value: 7)
let result = await Task {
counter.value // Compiles: 'counter' was copied into the task
}.value
return result // 7 — and the original value is untouched
}
Notice what the compiler didn’t have to do for the struct: no analysis of who else holds a reference, because there are no shared references. A copy crossed the boundary. That’s this post in miniature — Swift 6 makes value semantics the path of least resistance.
One framing note: Swift 6 doesn’t hate classes — it hates unproven sharing. The rest of this post is about what the compiler accepts and how to read its verdicts as design guidance.
2. Sendable: The New Interface Between Value and Reference
Sendable (SE-0302, Swift 5.5 / Xcode 13) is the protocol that marks a type as safe to cross an isolation domain. It’s the concurrency-era interface between value and reference types — the closest thing Swift has to “this type is safe to share.”
The asymmetry between structs and classes is baked into the definition:
Structs conform implicitly. If every stored property is Sendable, the struct is — you don’t even have to write the conformance. Note the nuance that trips people up: properties can be var. Sendable doesn’t require immutability, because a struct copy is independent — there’s no shared state to race on.
Classes never conform implicitly. You must declare the conformance, and then the compiler audits you:
struct User: Sendable { // conformance is verified, not trusted
let id: UUID
let name: String
let favoriteGenres: [String]
}
struct Draft: Sendable { // var properties are fine — copies are independent
var text: String
var isPublished: Bool
}
final class ImmutableUser: Sendable { // checked: final + all-let Sendable properties
let id: UUID
let name: String
init(id: UUID, name: String) {
self.id = id
self.name = name
}
}
final class MutableUser { // cannot cross a boundary, full stop
var name: String
init(name: String) { self.name = name }
}
// Adding `: Sendable` to MutableUser:
// Error: stored property 'name' of 'Sendable'-conforming class is mutable
Read those rules again — they’re the whole argument: the compiler only trusts a class when it can prove the class is effectively a struct. “Final, immutable, no sharing” is value semantics wearing a trench coat. Mutable state needs isolation (section 3) or an escape hatch.
The Danger of @unchecked Sendable
The escape hatch is @unchecked Sendable — and it is how most Swift 6 migrations get into trouble:
final class ThreadSafeCounter: @unchecked Sendable {
private let lock = NSLock()
private var count = 0
func increment() {
lock.lock()
count += 1
lock.unlock()
}
}
This compiles. And that’s exactly the problem: with @unchecked, the compiler stops checking the type entirely. Add a property next month without locking it, and Swift 6 mode won’t say a word. Your first data race will arrive via a crash report at 2 AM, not a build failure at 2 PM.
@unchecked is occasionally legitimate — a class that synchronizes all of its own state with a lock is the classic valid case. But treat it like force unwrap: every occurrence is debt, and it should carry a comment explaining the invariant it protects. In my experience, most @unchecked conformances in migrating codebases were a symptom of “this should have been a struct or an actor.”
any Sendable and Existential Isolation
One more Sendable corner worth knowing: heterogeneous collections of crossable values.
let payload: [any Sendable] = [User(id: UUID(), name: "Ada", favoriteGenres: ["jazz"]), 42, "hello"]
any Sendable (the any syntax is SE-0335, Swift 5.7) lets you collect values of different types that can all cross a boundary — one of the few places you deal with Sendable as a box rather than a conformance. It fits the protocol-composition style from Why Swift Is Protocol-Oriented (And Why That Matters): small value types conforming to focused protocols, each safe to hand anywhere.
For legacy code you don’t control, @preconcurrency (SE-0337, Swift 5.10 / Xcode 15.3) downgrades Sendable checking on imported types — a migration tool, not a destination.
3. Actors Are Reference Types: Where Classes Still Belong
Here’s the sentence that resolves most of the confusion I see: an actor is a class. It’s a reference type, with all the sharing semantics of a class — plus compiler-enforced mutual exclusion on its state.
The old advice — “use a class for shared mutable state” — doesn’t die in Swift 6; it gets an upgrade: shared mutable state now lives inside an actor (or a global-actor-isolated class, section 4), and everything else is a struct.
Watch the class-to-actor refactor — it’s the most satisfying ten-minute migration in Swift:
// Before: a class guarding state with a lock you wrote yourself
final class TokenStore {
private var token: String?
private let lock = NSLock()
func setToken(_ t: String) {
lock.lock()
token = t
lock.unlock()
}
func currentToken() -> String? {
lock.lock()
defer { lock.unlock() }
return token
}
}
// After: the same shape, with the lock deleted and isolation enforced
actor TokenStore {
private var token: String?
func setToken(_ t: String) { token = t }
func currentToken() -> String? { token }
}
Same architecture. Zero locking code. The compiler now guarantees what you used to pray about.
The complementary move: values flowing into actors should be structs.
struct AuthToken: Sendable {
let value: String
let expiresAt: Date
}
actor TokenStore {
private var current: AuthToken?
func store(_ t: AuthToken) {
current = t // a copy lands in the actor — no shared reference to race on
}
}
One subtlety worth internalizing: actors are reference types, so they’re still fully subject to ARC. A strong reference cycle between an actor and an object it holds is still a leak — weak references, [weak self], and the rest still apply, as I covered in iOS Memory Management: From ARC to Retain Cycles. Isolation changes the rules of mutation, not the rules of memory.
4. @MainActor and Classes: UI State vs Pure Logic
The second home for classes in Swift 6 is the main actor. Your ViewModel is the classic case: it’s a class precisely because it owns mutable state that the UI observes — the pattern I detailed in MVVM with Clean Architecture in iOS: A Practical Guide. Swift 6 doesn’t change that. It just makes the isolation explicit and enforceable:
@MainActor
@Observable // iOS 17+
final class CartViewModel {
var items: [CartItem] = []
var isCheckingOut = false
func add(_ item: CartItem) {
items.append(item)
}
}
// The model that flows in and out of it is a struct:
struct CartItem: Identifiable, Sendable {
let id: UUID
let name: String
let unitPrice: Decimal
var quantity: Int
}
This split is the Swift 6 sweet spot: one @MainActor class owns and mutates screen state; every value that crosses a boundary — network decoding, background processing, persistence mappers — is a Sendable struct. The compiler polices the seam.
The nonisolated keyword sharpens that seam. You can carve read-only, state-free logic out of an isolated type:
extension CartViewModel {
nonisolated func analyticsLabel() -> String {
"cart" // fine: touches no isolated state
}
nonisolated var total: Decimal {
items.reduce(0) { $0 + $1.unitPrice }
// Error: main-actor-isolated property 'items' cannot be
// referenced from a nonisolated context
}
}
That error is the feature: the compiler just showed you exactly where your state boundary is. And if you’re starting a project in 2026, note that Swift 6.2 (Xcode 26) defaults new projects to default MainActor isolation (SE-0466, part of the Approachable Concurrency effort) — UI types are assumed main-actor-isolated unless you say otherwise. The split above works identically; the compiler just assumes more on your behalf.
5. The Escape Hatches: nonisolated(unsafe) and sending
Every safety system needs a documented way out, and Swift 6 has two. Both are worth knowing; neither should be your default.
nonisolated(unsafe) (Swift 5.10, Xcode 15.3) marks a stored property or function as exempt from isolation checking. Its most famous use is singletons:
@MainActor
final class Analytics {
nonisolated(unsafe) static var shared: Analytics?
// Touchable from anywhere — with the safety checks switched off.
}
Note the pattern: nonisolated(unsafe) is the explicit acknowledgment that you’re breaking the rules. Keep a mental tally of how many you have. If the count climbs past a handful, the design is wrong, not the compiler.
sending (SE-0430, Swift 6.0) is the subtler one. It lets a function transfer a non-Sendable value across an isolation domain when the compiler can prove no other reference survives the transfer:
func process(_ request: sending Request) async { ... }
It’s genuinely useful in performance-sensitive systems code — but my honest guidance: if you’re reaching for sending in an app-level codebase, ask why the value isn’t a struct. sending is for carefully-proven transfers of reference types; a struct sidesteps the question by construction.
6. Copy Costs vs Reference Sharing Under Concurrency
The classic objection to structs — “but copying is expensive, classes just share a pointer” — needs re-examination in Swift 6, because sharing is no longer free. It’s now gated behind isolation, and isolation costs a hop.
Every call to an actor from outside is an await — a suspension, a hop onto the actor’s executor, a potential queueing delay. Copying a struct costs a fixed amount of memory traffic (and for collections, copy-on-write means most copies share storage until a mutation — the mechanics are in the companion post). Under concurrency, the comparison is no longer “copy vs pointer” but “predictable copy vs synchronization”:
// Struct snapshot: one copy, zero synchronization
let snapshot = cart.items
let total = await Task {
snapshot.reduce(0) { $0 + $1.unitPrice } // reads a private copy
}.value
// Actor per item: N hops
for item in cart.items {
await store.add(item) // each await suspends and hops
}
I’ve seen teams “optimize” by replacing structs with a shared class — only to discover the shared state now requires an actor (or locks), and every read became an await. The copies they avoided cost nanoseconds; the hops they introduced cost microseconds under contention. Under concurrency, structs win by default: copies are local, cheap, and need no coordination.
7. Swift 6 Migration Pain Points (and How to Fix Them)
If you’re migrating an existing codebase to Swift 6 mode, these are the wounds you’ll see most often. Each one has a structural fix, and the structural fix usually rhymes with “make it a struct.”
Pain 1: @unchecked Sendable everywhere. The seductive fix-it. Every one you accept is a race you’ve promised to police manually. Audit them in a dedicated pass: immutable ones become final classes with checked conformance (or structs), mutable ones become actors.
Pain 2: var structs that wrap a class. A struct is only as Sendable as its properties — one reference-type property poisons the whole value:
final class ProfileImage {
let data: Data
}
struct Draft {
var text: String
var image: ProfileImage // not Sendable → Draft is not Sendable
}
func autosave() {
var draft = Draft(text: "hello", image: ProfileImage(data: Data()))
Task {
await persist(draft)
// Error: sending 'draft' risks causing data races; later
// accesses to 'draft' could race
}
draft.text = "edited after autosave kicked off"
}
The compiler saw straight through the struct — it’s a class in disguise. Fix: make the image a Sendable value (a struct, or just Data), or hand the task an immutable snapshot and stop touching the original.
Pain 3: non-Sendable classes in Task and async let. This is the error you’ll see in a hundred places:
final class UserSession {
var token: String?
}
func loadProfile() async throws -> Profile {
let session = UserSession()
async let profile = fetchProfile(for: session)
// Error: sending 'session' risks causing data races
return try await profile
}
The fix that sticks: extract the value the task actually needs and make it a struct:
struct SessionCredentials: Sendable {
let token: String
}
Pain 4: UI-adjacent models as classes. Model classes shuffled between network callbacks, caches, and views are the migration’s biggest drag. Apply the section-4 split: keep the @MainActor class that owns state, demote everything it moves around to Sendable structs.
Pain 5: singletons. A global mutable class is the worst possible citizen in Swift 6 mode — every access from everywhere is a potential violation. Either the state is configuration (make it a frozen struct) or it’s genuinely shared and mutable (make it an actor). There is no third option that the compiler will leave you alone about.
8. When the Compiler Steers You: The Decision Framework
After migrating a few codebases, I’ve distilled the Swift 6 decision framework to four lines:
- Data that crosses boundaries — DTOs, domain models, results, payloads — is a
Sendablestruct. It’s safe by construction and costs nothing to hand around. - Shared mutable state — caches, stores, sessions — lives in an actor (or a
@MainActorclass when the UI owns it). - Stateless logic — strategies, formatters, validators — is a struct. Pure functions have no state to race on; the Strategy pattern in particular becomes trivial and safe as a value type, as shown in Design Patterns in Swift: A Practical Guide.
- Identity,
deinit, and Objective-C interop — the remaining reasons to touch classes — are unchanged from Swift 5.
And here’s the senior trick for the whole migration: when the compiler rejects your type, read the error as design feedback. Swift 6 is remarkably consistent — if the fix-its on offer are Sendable, @unchecked, nonisolated, or sending, ask yourself whether a struct would simply make the error disappear. In my experience, it does — roughly 80% of the time. The other 20% is real shared state, and now you know exactly where it lives.
Conclusion
In Swift 5, the struct-vs-class decision was advice: “prefer structs, use classes when you must.” In Swift 6, it’s architecture: the compiler enforces the boundary between values that travel and state that stays put.
That’s a gift, even when the migration hurts. Every error the compiler raises is a data race that will never ship; every struct you write in response is a type that can be handed to any task, actor, or thread — the safety argument made once, at compile time, instead of in code review forever. The fundamentals from the companion post haven’t changed; what changed is that Swift 6 now backs you up when you choose right and stops you when you don’t. Use structs by default, put shared mutable state behind isolation, and let the compiler be the enforcer you never had.
Key Takeaways
- Swift 6 language mode makes concurrency warnings into errors (Xcode 16+, September 2024). Xcode 26 still defaults new projects to Swift 5 mode — with Approachable Concurrency and default MainActor isolation switched on. The struct-vs-class decision is now compiler-enforced, not stylistic.
- Structs are implicitly
Sendablewhen their properties are —varproperties are fine, because copies are independent. Classes must declareSendableand pass an audit: final, immutable, all-Sendableproperties. @unchecked Sendableis debt. It disables checking for the whole type; every use needs a documented invariant and an audit trail. Most occurrences mean “this should be a struct or an actor.”- Actors are classes — reference types with enforced isolation. Shared mutable state moves inside actors; values flowing in and out are structs.
- The Swift 6 split:
@MainActorclasses own UI state;Sendablestructs carry everything else across boundaries.nonisolatedmarks the seam;nonisolated(unsafe)andsendingare escape hatches, not defaults. - Under concurrency, copies beat sharing: a struct copy is local and coordination-free; sharing now demands isolation, and every isolation hop costs an
await. - Migration pain points have structural fixes:
@uncheckedsprawl, structs wrapping classes, non-Sendable captures inTask/async let, UI models as classes, and singletons — all resolve by moving the value to a struct and the shared state to an actor. - Read compiler errors as design feedback. When the fix-its are
Sendable/@unchecked/nonisolated/sending, a struct usually makes the error disappear — and that’s the compiler telling you which type you meant.