Problem

When working with Swift concurrency, you might need to conform actor-isolated types to protocols that aren’t isolated. This is the most common case:

protocol CustomStringConvertible {
    var description: String // synchronous access
}

@MainActor
struct Person: CustomStringConvertible {
    let name: String
    // đź’Ą Main actor-isolated property 'description' cannot
    // be used to satisfy nonisolated protocol requirement
    var description: String { name }
}

A key realization to understand this is that a protocol defines not only methods and properties but also the isolation context, or lack of in this case, where it is called.

The problem is that synchronous protocols are called freely from outside the actor. However, the actor is expected to prevent parallel execution, which requires calls from the outside to be asynchronous. This is so they can be suspended and wait for exclusive access. So the protocol should be instead:

protocol IsolableCustomStringConvertible {
    var description: String { get async } // asynchronous access
}

But what if you are not at liberty to change the protocol?

Solution: Add nonisolated to the protocol implementation

@MainActor
final class Person: CustomStringConvertible {
    let name: String
    init(name: String) {
        self.name = name
    }
    nonisolated var description: String {
        name
    }
}

Marking the property as nonisolated effectively lifts it out of the actor’s domain, allowing synchronous access without runtime suspension. This is safe only if the returned values are Sendable and don’t introduce data races. Such is the case with String, a Sendable value type.

As explained in SE-0434 Usability of global-actor-isolated types, when accessing a Sendable property in a value type, any potential data race would have to be on the memory storing that property. Swift's concurrency system ensures that each thread gets exclusive access to a value instance before accessing one of its properties. Then the thread gets its own copy of the property, so effectively there is no data shared, which eliminates the possibility of a data race.

However, this won’t work with non Sendable reference types.

Solution: Use MainActor.assumeIsolated

Problem: non-actor isolated reference types are not safe to use from nonisolated contexts.

// same example, but replaced String with this reference type
class PersonName {
    let value: String
    init(value: String) {
        self.value = value
    }
}

@MainActor
final class Person: CustomStringConvertible {
    let name: PersonName
    init(name: String) {
        self.name = PersonName(value: name)
    }
    nonisolated var description: String {
        // đź’Ą Main actor-isolated property 'name' can not 
        // be referenced from a nonisolated context
        name.value
    }
}

This message says the compiler can’t guarantee safety. To which we can reply assume this will always be isolated to the main thread:

nonisolated var description: String {
    MainActor.assumeIsolated {
        name.value
    }
}

The compiler will trust but verify, enforcing the main-thread requirement at runtime with a _dispatch_assert_queue_fail assertion. If we violate this promise Xcode stops at a frame where _dispatch_log presents this message.

"BUG IN CLIENT OF LIBDISPATCH: Assertion failed: "
"%sBlock was %sexpected to execute on queue [%s (%p)]"

I tried unsuccessfully to output the message to the Xcode console to avoid opening the frame. Apparently you have to manually click there to see it.

Solution: Use a Data Transfer Object

Instead of directly conforming our actor-isolated Person to Codable, we can create an intermediate non-isolated type (a DTO) that handles the serialization. The DTO can safely implement Codable since it’s not actor-isolated, while our Person class maintains its actor isolation guarantees. Less ergonomic than a Codable Person but safer since it can be used with non main actors.

struct PersonDTO: Codable {
    let name: String
}

extension Person {
    func encode() throws -> Data {
        let dto = PersonDTO(name: name.value)
        let encoder = JSONEncoder()
        return try encoder.encode(dto)
    }
    
    static func decode(from data: Data) throws -> Person {
        let decoder = JSONDecoder()
        let dto = try decoder.decode(PersonDTO.self, from: data)
        return Person(name: dto.name)
    }
}

Conclusion

If these solutions feel unsatisfying, you’re not alone. Conformance to non-isolated protocols is one of the pain points mentioned in Improving the approachability of data-race safety:

Writing a single-threaded program is surprisingly difficult under the Swift 6 language mode.

  • global and static variables,
  • conformances of main-actor-isolated types to non-isolated protocols,
  • class deinitializers,
  • overrides of non-isolated superclass methods in a main-actor-isolated subclass, and
  • calls to main-actor-isolated functions from the platform SDK.

Do they sound familiar?

Class deinitializers are solved in Swift 6.1 SE-0371 Isolated synchronous deinit. The rest are problematic and there is no language-level solution yet.

The vision for Global and static variables is to opt-in a whole module to assume single-thread mode. An alternative is to make them @MainActor (similar thing), or Sendable, possibly @unchecked with internal synchronization.

The vision for conformances of main-actor types to non-isolated protocols is for the protocol to inherit the actor’s isolation domain. This means that even if CustomStringConvertible has no isolation requirements itself, implementing it from a @MainActor type would restrict access to protocol methods (like description) to the main actor only.

While these are current directions for the future, I wanted to share the workarounds that I'm using today.