No way to preserve type attributes in generic closure-wrapping functions

There's no way to write a generic closure wrapper that preserves type attributes (@MainActor, @Sendable, custom global actors).

Example

@globalActor actor MyActor { static let shared = MyActor() }

func inject<T>(_ f: @escaping (Int) -> T) -> () -> T {
    { f(42) }
}

let f = inject { @MyActor x in print(x) }
// error: converting '@MyActor @Sendable (Int) -> ()'
//        to '(Int) -> ()' loses global actor 'MyActor'

The closure is @MyActor, but (Int) -> T can't carry that. Attribute lost.

To preserve it, you need a separate overload per attribute:

func inject<T>(_ f: @escaping @MyActor (Int) -> T) -> @MyActor () -> T { ... }

This is not a viable option - throws/async combinations already require 4 overloads, each attribute doubles that, and custom global actors defined by downstream users make it impossible. Moreover, you can't even have both @MainActor and @MainActor @Sendable overloads - the compiler considers them a redeclaration.

Discussion

Has there been any discussion about parameterizing over type attributes? Something like:

func inject<T, @attributes Attr>(_ f: @escaping @Attr (Int) -> T) -> @Attr () -> T

This seems like a natural next step after parameter packs solved the arity problem - type attributes are the remaining axis of the overload explosion. Curious if anyone has run into this or if there are existing proposals/pitches I missed.

1 Like

For the case of actor isolation specifically, isn't this what @isolated(any) is for?

2 Likes

As @bbrk24 mentioned, with @isolated(any) you can, at least, retain the isolation dynamically:

func inject<T: Sendable>(
	_ f: @escaping @isolated(any) (Int) -> T)
-> () async -> T {
	{ await f(42) }
}

// or

func inject<T>(
	_ f: @escaping @isolated(any) (Int) -> sending T)
-> () async -> T {
	{ await f(42) }
}

The proposal: @isolated(any) Function Types

1 Like

Thanks for the pointer to @isolated(any) — but I don't think it addresses the original post.

@isolated(any) preserves isolation dynamically, but erases it statically and changes the call site:

// What a generic decorator preserving the attribute would give back:
let f: @MainActor (Int) -> T = wrap(g)
f(42)             // sync, from a MainActor context

// What @isolated(any) gives back:
let f: @isolated(any) (Int) -> T = wrap(g)
await f(42)       // async, even when both sides are on MainActor

That await is the regression the original post was trying to avoid — a generic wrapper silently turning a sync API into an async one.

@isolated(any) is the right tool when you genuinely don't know the actor and want correct executor enqueueing (Task.init is the canonical example), and the proposal solves that well. But that's a different problem from preserving a static attribute through a generic wrapper, and the gap is still open.

1 Like