`Sendable` values in non-`Sendable` containers

Disclaimer: this code is simplified for demonstration purposes.

Suppose I have a value only available asynchronously:

struct Source: Sendable {
    var someAsyncValue: Int {
        get async {
            42
        }
    }
}

That value is a part of a dependency:

class Dependency {
    let source = Source()

    var someAsyncValue: Int {
        get async {
            await source.someAsyncValue
        }
    }
}

And this is then used in some code:

struct Container {

    let dependency = Dependency()

    @MainActor func worksFine() async { 
        _ = await self.dependency.source.someAsyncValue // ✅
    }
}

This is a repeated pattern, so I introduce a "shortcut" into Dependency, so I don't have to write .source every time:

extension Dependency {
    var someAsyncValue: Int {
        get async {
            await source.someAsyncValue
        }
    }
}

Predictably, this doesn't work, because dependency is neither Sendable, nor @MainActor.

struct Container {

    let dependency = Dependency()

    @MainActor func doesNotWork() async {
        _ = await self.dependency.someAsyncValue // ❌ Non-sendable type 'Dependency' exiting main actor-isolated context in call to nonisolated property 'someAsyncValue' cannot cross actor boundary
    }
}

This Dependency could easily be made Sendable, but the real one has mutable state, so that's out. It could be made into an actor or isolated to @MainActor, but the real version also conforms to protocols, which is another can of worms.

The method on Container is @MainActor because some other non-Sendable classes are later involved, calling more async methods. Removing @MainActor would just shift the problem elsewhere.

I could mark someAsyncValue with @MainActor, but it's a simple value... there is really no reason it should have to be isolated.

I only want to be able to avoid having to write .source, not rearchitect the whole app :sweat_smile:. My question is whether I've overlooked something (please) or whether I'll just have to bite the bullet here.

It works (I think) if you turn your var someAsyncValue into a function and pass the caller's isolation along:

class Dependency {
    let source = Source()

    func someAsyncValue(isolation: isolated (any Actor)? = #isolation) async -> Int {
        await source.someAsyncValue
    }
}

struct Container {
    let dependency = Dependency()

    @MainActor func worksFineToo() async {
        _ = await self.dependency.someAsyncValue() // ✅
    }
}

This compiles in Swift 6 mode and seems to work fine in my very limited testing.

So you have to add a pair of parentheses at the call site and the implementation of the convenience function requires more boilerplate. You have to decide whether this is good enough for you.

Passing a dynamic isolation to computed properties is mentioned as a future direction in SE-0420. And if [Pitch] Inherit isolation by default for async functions gets implemented, your code should work as-is, I think.

1 Like