Isolated Conformances in Swift 6.2: Solving the Most Common Migration Pain Point
You have a @MainActor view model. It holds published state, drives your UI, and works perfectly — until you try to
conform it to Hashable, Codable, or any protocol whose requirements aren’t isolated to the main actor. Suddenly the
compiler fires off errors about non-isolated conformances, and you find yourself littering your code with nonisolated
markers, @unchecked Sendable, or awkward wrapper types just to satisfy the type checker.
Swift 6.2 eliminates this friction with isolated conformances — the ability to declare that an entire protocol
conformance lives on a specific actor. This post walks through the feature end to end: the problem it solves, the
syntax, the runtime semantics, and the sharp edges you need to watch for. We won’t cover the broader Swift 6.2
concurrency model changes (the new @concurrent attribute or default isolation rules) — those deserve their own
dedicated posts.
Contents
- The Problem
- Isolated Conformances: The Solution
- How the Compiler Enforces Isolation
- Advanced Usage and Edge Cases
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Consider a common pattern in any SwiftUI app: a view model marked @MainActor that you also need to make Hashable so
it can serve as a navigation destination or collection identifier.
@MainActor
final class MovieViewModel: ObservableObject {
@Published var title: String
@Published var rating: Double
let id: UUID
init(title: String, rating: Double, id: UUID = UUID()) {
self.title = title
self.rating = rating
self.id = id
}
}
// In Swift 6 strict concurrency, this conformance is problematic:
extension MovieViewModel: Hashable {
static func == (lhs: MovieViewModel, rhs: MovieViewModel) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
In Swift 6’s strict concurrency mode, the compiler rejects this. The Hashable protocol’s requirements — == and
hash(into:) — are not isolated to any actor. But MovieViewModel is @MainActor-isolated, so its properties can only
be accessed from the main actor. The protocol expects these methods to be callable from anywhere, creating a data-safety
conflict.
Before Swift 6.2, you had a few unappealing options:
// Option 1: Mark every requirement as nonisolated
extension MovieViewModel: Hashable {
nonisolated static func == (lhs: MovieViewModel, rhs: MovieViewModel) -> Bool {
lhs.id == rhs.id // Only safe because `id` is a `let` constant
}
nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(id) // Same — only works for immutable properties
}
}
// Option 2: Use @unchecked Sendable and hope for the best
// Option 3: Restructure your entire type to avoid the conflict
Option 1 works here because id is an immutable let property, but if you need to hash on mutable state — or if the
protocol requirement is more involved — nonisolated is either unsafe or impossible. Option 2 silences the compiler but
removes the safety guarantees you adopted strict concurrency for in the first place. Option 3 is often impractical in a
large codebase.
This was, by a wide margin, the most common pain point reported during Swift 6 migration. The Swift team acknowledged it directly in the WWDC25 session “What’s New in Swift” (session 245), calling out isolated conformances as a targeted solution.
Isolated Conformances: The Solution
Swift 6.2 introduces a clean syntax: annotate the conformance itself with the actor isolation.
@MainActor
final class MovieViewModel: ObservableObject {
@Published var title: String
@Published var rating: Double
let id: UUID
init(title: String, rating: Double, id: UUID = UUID()) {
self.title = title
self.rating = rating
self.id = id
}
}
extension MovieViewModel: @MainActor Hashable {
static func == (lhs: MovieViewModel, rhs: MovieViewModel) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
The key change is @MainActor Hashable in the extension declaration. This tells the compiler: “Every requirement in
this conformance is satisfied by implementations that run on the main actor.” The methods inside the extension inherit
@MainActor isolation automatically — no need to annotate each one individually.
The Syntax in Detail
The isolated conformance annotation goes between the colon and the protocol name in any conformance declaration:
// In an extension (most common)
extension MyType: @MainActor SomeProtocol { ... }
// In the type declaration itself
class MyType: @MainActor SomeProtocol { ... }
// With multiple protocols — annotate each one independently
extension MyType: @MainActor Hashable, @MainActor CustomStringConvertible { ... }
Note: The isolation annotation applies per-protocol. If you conform to multiple protocols in a single extension, you can choose which ones are isolated and which are not. Each protocol conformance is independent.
A More Realistic Example
Let’s look at a scenario where nonisolated simply cannot help — a @MainActor type conforming to a protocol that
requires access to mutable state.
protocol SceneRenderable {
var renderPriority: Int { get }
func prepareForRender() -> RenderConfiguration
}
struct RenderConfiguration {
let layerCount: Int
let useAntialiasing: Bool
}
@MainActor
final class PixarSceneController: ObservableObject {
@Published var activeCharacters: [String]
@Published var lightingIntensity: Double
var renderPriority: Int {
activeCharacters.count > 5 ? 1 : 10
}
init(characters: [String], lighting: Double) {
self.activeCharacters = characters
self.lightingIntensity = lighting
}
func prepareForRender() -> RenderConfiguration {
// Accesses mutable @Published properties — must be on MainActor
RenderConfiguration(
layerCount: activeCharacters.count * 2,
useAntialiasing: lightingIntensity > 0.8
)
}
}
// Before Swift 6.2: impossible to satisfy without @unchecked Sendable
// or restructuring the type.
// With Swift 6.2:
extension PixarSceneController: @MainActor SceneRenderable {}
Because renderPriority reads from activeCharacters (a mutable @Published property), marking it nonisolated would
be a data race. The isolated conformance states upfront that this conformance only operates on the main actor, and the
compiler accepts it without any nonisolated workarounds.
How the Compiler Enforces Isolation
Isolated conformances are not just syntax sugar. They change how the compiler reasons about your type when used through a protocol-typed value.
Existential Usage
When you use an isolated conformance through an existential (any SomeProtocol), the compiler requires that the call
site also be on the correct actor:
@MainActor
func renderScene(renderable: any SceneRenderable) {
// This is fine — we're already on @MainActor
let config = renderable.prepareForRender()
print("Rendering \(config.layerCount) layers")
}
// This function is NOT on @MainActor — the compiler rejects it
func renderFromBackground(renderable: any SceneRenderable) {
// Error: call to main actor-isolated function
// in a synchronous nonisolated context
let config = renderable.prepareForRender()
}
This is the key safety property. The compiler will not let you call into an isolated conformance from the wrong isolation domain. You get a compile-time error, not a runtime crash.
Generic Usage
When a type with an isolated conformance is used as a generic parameter, the constraint carries the isolation requirement forward:
@MainActor
func processRenderable<T: SceneRenderable>(_ item: T) {
// Fine: generic context inherits the @MainActor isolation
let priority = item.renderPriority
print("Priority: \(priority)")
}
The compiler tracks which conformances are isolated and propagates those requirements through generic constraints. If
you try to call processRenderable from a non-isolated context with a type that has a @MainActor-isolated conformance
to SceneRenderable, the compiler will flag it.
Tip: Think of an isolated conformance as a contract: “I implement this protocol, but only when you talk to me on my actor.” The compiler enforces both sides of that contract — the implementation side and the call site.
Advanced Usage and Edge Cases
Combining Isolated and Non-Isolated Conformances
A single type can have some conformances isolated and others not. This is common when a type has both identity-based
protocols (which can be nonisolated because they use immutable properties) and behavior-based protocols (which need
actor isolation).
@MainActor
final class ToyBoxInventory: ObservableObject {
let id: UUID
@Published var toys: [String]
@Published var lastUpdated: Date
init(id: UUID = UUID(), toys: [String]) {
self.id = id
self.toys = toys
self.lastUpdated = Date()
}
}
// Non-isolated: only uses immutable `id`
extension ToyBoxInventory: Equatable {
nonisolated static func == (lhs: ToyBoxInventory, rhs: ToyBoxInventory) -> Bool {
lhs.id == rhs.id
}
}
// Isolated: needs access to mutable @Published state
extension ToyBoxInventory: @MainActor CustomStringConvertible {
var description: String {
"\(toys.count) toys, last updated \(lastUpdated.formatted())"
}
}
This pattern gives you the best of both worlds: Equatable works from any isolation context, while
CustomStringConvertible is explicitly main-actor-bound because it reads mutable state.
Isolated Conformances with Custom Actors
The feature is not limited to @MainActor. You can use any global actor:
@globalActor
actor RenderActor {
static let shared = RenderActor()
}
@RenderActor
final class FrameProcessor {
var frameBuffer: [UInt8] = []
var frameCount: Int = 0
}
extension FrameProcessor: @RenderActor CustomStringConvertible {
var description: String {
"FrameProcessor: \(frameCount) frames, buffer size \(frameBuffer.count)"
}
}
Warning: Isolated conformances to custom global actors follow the same rules as
@MainActor, but be mindful that custom actors don’t share@MainActor’s guarantee of running on the main thread. If your protocol requirement interacts with UIKit or AppKit APIs, stick with@MainActor.
Retroactive Isolated Conformances
You can add an isolated conformance to a type you don’t own, as long as the conformance itself is retroactive:
// Conforming a third-party type to your own protocol
extension ThirdPartyViewModel: @MainActor SceneRenderable {
var renderPriority: Int { 5 }
func prepareForRender() -> RenderConfiguration {
RenderConfiguration(layerCount: 1, useAntialiasing: false)
}
}
The standard Swift rules for retroactive conformances still apply — you need to own either the type or the protocol (or both). Isolated conformances do not change those rules; they only add the isolation annotation on top.
Interaction with nonisolated
You can still mark individual methods as nonisolated inside an isolated conformance if a specific requirement doesn’t
need actor access. However, this is rarely needed — if a requirement doesn’t access isolated state, it’s usually cleaner
to put it in a separate, non-isolated conformance.
extension MovieViewModel: @MainActor CustomDebugStringConvertible {
// This inherits @MainActor from the conformance
var debugDescription: String {
"MovieViewModel(title: \(title), rating: \(rating))"
}
}
Performance Considerations
Isolated conformances carry no runtime overhead compared to regular conformances when called from the correct actor.
If you call a @MainActor-isolated conformance from main-actor-isolated code, the call is a direct dispatch — no actor
hop, no suspension point.
The cost surfaces when you need to call into the conformance from a different isolation domain. In that case, you need
an await and an actor hop, just like any cross-actor call:
// From a background context, you'd need to hop to MainActor
func backgroundWork(viewModel: MovieViewModel) async {
let hash = await MainActor.run {
var hasher = Hasher()
viewModel.hash(into: &hasher)
return hasher.finalize()
}
print("Hash: \(hash)")
}
This is not a hidden cost — the compiler forces you to write the await, making the actor hop visible in your code. If
you find yourself frequently hopping actors to use a conformance, that’s a design signal: either the conformance should
not be isolated, or the calling code should share the same isolation.
Apple Docs:
GlobalActor— Swift Standard Library
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
View model conforming to Hashable using only immutable properties | Prefer nonisolated — simpler, no isolation overhead. |
@MainActor type reading mutable @Published state in a conformance | Use an isolated conformance. This is the primary use case. |
| Conformance needed from background tasks or non-main-actor code | Avoid — they force callers onto the actor. Restructure instead. |
| Migrating a large Swift 5 codebase to Swift 6 | Use as a bridge to remove compiler errors without restructuring. |
| Custom global actor types with protocol conformances | Use isolated conformances. Same rules as @MainActor. |
Protocol with async requirements | Usually unnecessary — async methods can already suspend across actors. |
The decision framework is straightforward: if your protocol requirement implementations need access to actor-isolated
mutable state, use an isolated conformance. If they only touch immutable properties or can be made nonisolated
without risk, keep them non-isolated for maximum flexibility.
Tip: During a Swift 6 migration, isolated conformances are one of the highest-value changes you can adopt. They directly eliminate the most common category of strict-concurrency errors without requiring you to rethink your architecture.
Summary
- Isolated conformances let you declare that a protocol conformance belongs to a specific actor using
extension MyType: @MainActor SomeProtocol. - They solve the most common Swift 6 migration pain point:
@MainActortypes that need to conform to protocols with non-isolated requirements. - The compiler enforces isolation at the call site — you cannot accidentally call into an isolated conformance from the wrong context.
- There is no runtime overhead when called from the correct actor. Cross-actor calls require an explicit
awaithop, keeping the cost visible. - Prefer
nonisolatedfor conformances that only access immutable state. Reserve isolated conformances for cases where protocol requirements genuinely need actor-isolated mutable state.
Isolated conformances are one piece of Swift 6.2’s broader push to make concurrency more approachable. To understand how
they fit alongside the new @concurrent attribute and the shift to single-threaded defaults, check out
Approachable Concurrency in Swift 6.2 for the full picture.