MainActor + KeyPathComparator + class = error

I’m running into a situation where the compiler shows an error and I’m not quite sure if it is my code or the compiler that is incorrect. Any input would be greatly appreciated!

My project uses Swift 6, MainActor default isolation, and approachable concurrency.

Note that there seems to be a related issue that is not really fixed yet: KeyPaths are still not properly Sendable in Swift 6.2 · Issue #84983 · swiftlang/swift · GitHub

Here are the relevant pieces:

  • I have a Swift UI view with a Table and a ViewModel that holds documents, i.e. a property of type [Document]
  • Usually, this Document would just be a struct but because there are a couple of things that seem to be expensive, it is a class with some lazy var properties. The idea is that these things will be computed once for the time the Document is visible in the Table. For example:
    final class Document: Identifiable {
        var id: UUID
        var title: String
        var lastViewedDate: Date
        private(set) lazy var formattedLastViewedDate: String = {
            // Do something expensive that is then shown in the table.
        }()
    }
    
  • To make sorting in the Table work, my ViewModel also has a property like this:
    var sortComparators: [KeyPathComparator<Document>]
    
  • (Not strictly relevant, but for context: The SwiftUI Table takes a a sortOrder parameter of type Binding<[Sort]> where Sort: SortComparator and I want to use the TableColumn inside it that effectively requires that Sort is the specific type KeyPathComparator<Compared>)

The issue is now with the KeyPathComparator<Document> type. Here’s what the compiler says when I try to create those:

var sortComparators: [KeyPathComparator<Document>] = [
    // 🛑 error: Type 'ReferenceWritableKeyPath<Document, String>' does not conform to the 'Sendable' protocol
    .init(\.title, order: .forward),
    // 🛑 error: Type 'ReferenceWritableKeyPath<Document, Date>' does not conform to the 'Sendable' protocol
    .init(\.lastModifiedDate, order: .forward),
    // Interestingly, there is no error here!
    .init(\.id, order: .forward)
] 

The KeyPathComparator initializer takes a parameter of type any KeyPath<Compared, Value> & Sendable, so that must be Sendable. And that’s the reason why I’m not sure if it’s a compiler issue: Should a key path to a property in a @MainActor class be Sendable? I’m not entirely sure. If the answer is no, the issue is clearly in my code and KeyPathComparator is practically unusable. That’s why I suspect it might be a compiler issue after all.

Note: The error goes away when specifying no default isolation or when using the Swift 5 language mode.

I tried this in Xcode 26.3 (Swift 6.2.4), Xcode 26.4 beta 3 (Swift 6.3), and the latest development snapshot (swift-DEVELOPMENT-SNAPSHOT-2026-03-16-a-osx.pkg) and they all behave the same.


But wait, there’s more! I found a workaround that makes this compile:

I was curious to find out why there was no error when creating the key path to the the id property and I noticed that removing the Identifiable conformance from Document makes that missing error appear. So I tinkered around a bit and I think the reason is that Identifiable is nonisolated, which makes the compiler happy to create a Sendable key path for some reason. So I simply defined my own nonisolated protocol and made my Document conform to that and that “fixes” the compiler error.

Here’s a minimal complete reproducer:

import Foundation

/// This is a class because we use `lazy var` to avoid recomputation of some things that are ony calculated for documents that are actually visible in the `Table`.
final class Document: Identifiable {
    var id: UUID = .init()
    var title: String = ""
}

let sortComparators: [KeyPathComparator<Document>] = [
    // 🛑 error: Type 'ReferenceWritableKeyPath<Document, String>' does not conform to the 'Sendable' protocol
    .init(\.title, order: .forward),
    // Interestingly, there is no error here. Presumably because `id` is defined in the `nonisolated Identifiable` protocol:
    .init(\.id, order: .forward)
]

// If this code is enabled, the build succeeds.
// Presumably this has to do with the fact that the compiler
// can see a `nonisolated` key path to these properties?
#if false
nonisolated protocol KeyPathWorkaround {}

nonisolated extension Document: KeyPathWorkaround {}

nonisolated extension KeyPathWorkaround where Self == Document {
    // Never called, so crashing is fine here:
    var title: String { fatalError() }
}
#endif

(Interestingly, the code behaves differently when switching Document from a class to a struct: The compiler error will show for the \.id key path, regardless of whether the workaround is present or not).

We covered this in SE-0418, it's outlined in #3 in that section, key path to a global-actor isolated property is not inferred to be & Sendable because key path subscripts and runtime functions don't have async variants and so they are not capable of switching to the required isolation.

1 Like

@xedin I think the problem is we closed KeyPaths are still not properly Sendable in Swift 6.2 · Issue #84983 · swiftlang/swift · GitHub when it turns out it isn't really fixed. I thought it was but I'd forgotten to switch to Swift 6 (which isn't the default why?).

If controller is MainActor isolated through a conformance that the behavior is correct in your example and key path cannot be Sendable.

Thank you both for your input!

@xedin: I had to read the section in the proposal a couple of times to finally understand it and now that I think I do, it sounds to me like everything is working as designed here. But that still leaves us in a situation where the most straightforward code to write is not working, i.e.:

@MainActor // Could be implicit thanks to the default actor isolation of the module.
struct Document {
    var title: String
}

// 🛑 error: Type 'WritableKeyPath<Document, String>' does not conform to the 'Sendable' protocol
let sortComparator = KeyPathComparator<Document>(\.title, order: .forward)

So if Document (regardless of whether it’s a struct or class) for whatever reason is or has to be @MainActor (not unreasonable, considering that this is the default in Xcode’s project template), then you cannot use KeyPathComparator – but you have to use that in some SwiftUI views.

Could it be that the SortComparator protocol’s requirement that all conformances must be Sendable is just too strict for practical use? (I tried real quick to factor out the Sendable requirement into a separate protocol by copying the SortComparator and KeyPathComparator as custom nonisolated types and removing the Sendable parts and then the above code unsurprisingly compiles.)

TL;DR: Could this be an issue in Foundation’s SortComparator being too strict?

1 Like

Yes, this might be the case, we don't really need to require key paths to be Sendable if the comparison is performed without crossing isolation boundary, this is especially true to MainActor by default mode where all operations are performed on the same actor.

I think the main issue here is that there is no way to make KeyPathComparator conditionally Sendable only if the key path is as well, there is a language feature that is missing here to make that work.

3 Likes

That confirms what I found when I tried to implement that non-Sendable variant of SortComparator: I couldn’t make it conditionally Sendable because the type of the stored key path would need to change.

So I guess I’ll use my workaround for now and maybe open an issue in the Foundation GitHub repository.

Thanks for your input!

1 Like

Yes, I think it might be worth opening an issue on Foundation even though it would be blocked on the compiler.

Done: Add a variant of `KeyPathComparator` that can be used with `@MainActor` types · Issue #1845 · swiftlang/swift-foundation · GitHub

I just realized something that is a better workaround than the protocol-based one I mentioned earlier and so very obvious that I’m wondering why I didn’t think about it earlier. I think the whole thing about the non-Sendable KeyPathComparator is still valid, but in my use case, this works:

Even though my Document type must be @MainActor because it is a class with lazy var properties, all the properties that I use in the KeyPathComparator can actually be made let to Sendable types and are therefore nonisolated (or to be completely precise: The properties I’m using are accessed through a @dynamicMemberLookup and the subscript for that can be marked nonisolated because the thing that is accessed in there is a let).

That is:

@dynamicMemberLookup
final class Document: Identifiable {
    var id: UUID { self.metadata.id } // Must be explicit to satisfy `Identifiable` requirement.
    let metadata: DocumentMetadata

    // Key paths now work in `KeyPathComparator` because this is `nonisolated`!
    nonisolated subscript<Property>(dynamicMember keyPath: KeyPath<DocumentMetadata, Property>) -> Property {
        self.metadata[keyPath: keyPath]
    }
}

struct DocumentMetadata: Sendable {
    var id: UUID
    var title: String
    var lastViewedDate: Date
}

Oh yes, that definitely works great if your types could are Sendable.