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

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)

ScenarioRecommendation
View model conforming to Hashable using only immutable propertiesPrefer nonisolated — simpler, no isolation overhead.
@MainActor type reading mutable @Published state in a conformanceUse an isolated conformance. This is the primary use case.
Conformance needed from background tasks or non-main-actor codeAvoid — they force callers onto the actor. Restructure instead.
Migrating a large Swift 5 codebase to Swift 6Use as a bridge to remove compiler errors without restructuring.
Custom global actor types with protocol conformancesUse isolated conformances. Same rules as @MainActor.
Protocol with async requirementsUsually 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: @MainActor types 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 await hop, keeping the cost visible.
  • Prefer nonisolated for 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.