[Pitch] @Reasync and @ReasyncMembers macros

Hello, Swift Evolution!

This is a new pitch that would add @Reasync and @ReasyncMembers macros to the standard library, or to an official swiftlang-org package.

The macros generate synchronous overloads of async functions by removing async and await keywords, allowing a single source of truth for functions that must exist in both synchronous and asynchronous forms.

The motivation for this proposal comes from maintaining swift-test-kit, a testing library that provides parallel APIs for Swift Testing and XCTest. swift-test-kit needs paired sync/async overloads for most of its assertion and evaluator machinery, and the duplication cost became real enough that I built these macros as a standalone package (swift-reasync). The macros are used extensively throughout swift-test-kit's source, and even its test suite, so I can test both sync and async overloads of an API without writing the same test method twice.

The PR can be found here. Please direct typos and other minor fixes there.

Konrad Malawski's (@ktoso) encouragement in the thread A case study for reasync prompted me to write this up as a proposal.

Summary of changes

Adds @Reasync and @ReasyncMembers macros that generate synchronous overloads of async functions by removing async and await keywords, allowing a single source of truth for functions that must exist in both synchronous and asynchronous forms.

Motivation

Swift developers frequently need the same function to exist in both synchronous and asynchronous forms. For example, a library that works with both synchronous and asynchronous user-provided closures cannot expose a single function that accepts either version. Swift does not currently offer a way to make a function generic over the async-ness of its parameters. The canonical workaround is to write the function twice: once as async and once as synchronous, with the two declarations differing only in the presence of the async and await keywords.

This pattern appears in the author's own library swift-test-kit, which offers a parallel API for Swift Testing and XCTest. swift-test-kit contains paired sync/async implementations across property-based, stateful, temporal, performance, and atomic evaluators, each duplicated to support both synchronous and asynchronous test bodies. In all of these cases, the synchronous function is identical to the async version, apart from the async and await keywords.

This duplication is not a small cost. Each paired implementation doubles the surface area that must be tested, documented, and kept in sync. Drift between the two versions is easy to introduce, since any bug fix, refactor, or behavioral change applied to one version but not the other produces inconsistency between the synchronous and asynchronous APIs.

The cost compounds over time and grows with the complexity of the function being duplicated. Consider the following overload from swift-test-kit, one of four XCTKForAll property-based testing overloads that each ship in both synchronous and asynchronous forms:

public func XCTKForAll<each T>(
    using generators    : repeat Generator<each T>,
    where precondition  : @escaping (repeat each T) -> Bool,
    examples            : @autoclosure () -> [(repeat each T)]  = [],
    message             : @autoclosure () -> String             = "",
    fileID              : StaticString                          = #fileID,
    file                : StaticString                          = #filePath,
    line                : UInt                                  = #line,
    column              : UInt                                  = #column,
    options             : TestOptions?                          = nil,
    _ property          : (repeat each T) async throws -> Void
) async
{
    await TKForAll(
        using:      repeat each generators,
        where:      precondition,
        examples:   examples,
        message:    message,
        fileID:     fileID,
        file:       file,
        line:       line,
        column:     column,
        options:    options ?? TestConfiguration.current,
        property,
        context:    failureContext
    )
}

Every parameter, default value, trivia detail, and call-site forwarding must be replicated exactly in the synchronous overload. swift-test-kit's property-based testing API has four ForAll overloads (differing in generator and precondition usage), each of which must ship in both synchronous and asynchronous forms. Across the library's Swift Testing and XCTest APIs, that produces 16 declarations to keep in sync for only one of many evaluator types (PBT, stateful, temporal, atomic, performance).

The Swift community has explored a language-level solution similar to rethrows. In the case study thread linked above, Swift compiler engineer Doug Gregor (@Douglas_Gregor) explained:

reasync is in a tricky place because the design is easy (just follow rethrows but with async), and the motivation is easy, but a decent implementation in the compiler is a bunch of work.

Moreover, it's almost a syntactic-sugar feature, because you can get nearly the same effect by duplicating the code into async and non-async versions. Indeed, now that we have macros, I'd be curious just how far one can get by implementing a peer macro that, when applied to an async function with async closure parameters, produces a synchronous version of that function that zaps the async from closure parameters as well as all of the awaits within the function body.

A full reasync language feature remains difficult for the reasons explored in that thread and elsewhere. So far, no proposal has advanced. But Doug Gregor's observation points at a narrower solution that does not require language-level changes: If the workaround is duplication, and the duplication is mechanical, then the duplication can reliably be generated by a macro.

This proposal formalizes that observation. The @Reasync peer macro is attached to an async function and synthesizes its synchronous overload by removing async and await. The @ReasyncMembers member macro does the same thing at the type scope, generating synchronous overloads for all functions declared within the type, and can be applied to structs, classes, enums, actors, and extensions.

This allows library authors to maintain a single source of truth, while Swift's overload resolution selects the appropriate version at the call site. The macros do not attempt to solve the general problem that a language-level reasync would solve, but they solve the common case that most library authors actually encounter in practice, and it does so with machinery that already exists in the language.

Proposed solution

Adding these macros to the standard library (or to a swiftlang-org package) would give library authors a canonical, shared solution to this problem.

The @Reasync macro is attached to an async function declaration. At compile time, it produces a synchronous overload of the function by removing async and await from the declaration and its body.

@Reasync
func run(
    _ body: () async throws -> Void
) async rethrows
{
    try await body()
}

// Generated by @Reasync:
// 
// func run(
//     _ body: () throws -> Void
// ) rethrows
// {
//     try body()
// }

The asynchronous declaration is the single source of truth. The synchronous overload is produced at compile time, and Swift's overload resolution selects the appropriate version at each call site based on the caller's context.

The transformation applies throughout the function, not just the signature. async let bindings become let bindings, for await loops become for loops, and await expressions are replaced by their synchronous equivalents:

@Reasync
func sum(
    _ a             : Int,
    _ b             : Int,
    using compute   : (Int) async -> Int
) async -> Int
{
    async let x : Int   = compute(a)
    async let y : Int   = compute(b)
    
    return await x + y
}

// Generated by @Reasync:
// 
// func sum(
//     _ a             : Int,
//     _ b             : Int,
//     using compute   : (Int) -> Int
// ) -> Int
// {
//     let x : Int   = compute(a)
//     let y : Int   = compute(b)
// 
//     return x + y
// }

All other attributes, modifiers, generic constraints, trivia, and documentation comments are preserved in the generated overload, so the synchronous version carries the same API-level presentation as the asynchronous source.

@ReasyncMembers applies the same transformation to every asynchronous function member of an annotated type or extension:

@ReasyncMembers
struct Operations
{
    func double(
        _ value: Int
    ) async -> Int
    {
        return value * 2
    }
    
    // Generated: synchronous overload of double(_:).
    
    func increment(
        _ value: Int
    ) -> Int
    {
        return value + 1
    }
    
    // No overload generated: increment(_:) is already synchronous.
}

Synchronous members are left unchanged. Members that are already annotated with @Reasync are skipped, since @Reasync generates its own overload. @ReasyncMembers can be applied to structs, classes, enums, actors, and extensions.

Returning to the motivating example, the duplication in swift-test-kit collapses to a single annotation:

@Reasync
public func XCTKForAll<each T>(
    using generators    : repeat Generator<each T>,
    // parameters omitted
    _ property          : (repeat each T) async throws -> Void
) async
{
    // body omitted
}

The synchronous overload is generated at compile time. 16 declarations across swift-test-kit's property-based testing API collapse to 8, with no possibility of drift between sync and async overloads.

The macros rely on Swift's own type-checking to validate the generated overload. If the body contains constructs that are inherently asynchronous, such as calls to actor-isolated methods or async-only APIs, the compiler rejects the generated overload with an error at the invalid expression. This makes the macros safe to use: A function can only be marked @Reasync if it can actually be made synchronous.

Implementation status

A full working implementation is available as a standalone package: swift-reasync, and is used extensively in swift-test-kit.

Two general peer macro issues affect the current implementation:

Neither issue is specific to these macros. Both of these issues affect any peer macro, and are filed upstream.

A workaround for the first issue is to declare nested types outside the annotated function.

A workaround for the second issue, when using DocC to generate documentation, is to create a separate DocC article for the synchronous overload. DocC recognizes the symbol, and will attach the article to the symbol when generating the documentation site.

Remaining sections

Detailed design, source and ABI compatibility, implications on adoption, alternatives considered, future directions, and acknowledgements are in the full proposal.

Feedback wanted

  • If the proposal is accepted, would you rather see these macros as part of the standard library or as a swiftlang-org package?
  • Are there transformations that would be valuable in practice, but are not covered by the current design?
15 Likes

No comment from me except please please name it lowercase @reasync.

2 Likes

I think this an absolutely fascinating proposal. Yet I find myself torn.

On the one hand, I have experienced this problem myself a number of times times. It even motivated a recent proposal. And in fact, that pitch for that received some pushback specifically because reasync would have been a much more general solution.

The main reason I find myself hesitating is because this is an area of high complexity. There are many permutations here, and they can come with surprising subtly (here's an example off the top of my head). Further, I can imagine a number of additional changes that could potentially be made to the language around async function syntax and behavior. My fear is that a mechanism like this would be an impediment.

Now, my fears could absolutely be unfounded. And I generally do not like putting off good ideas because there are promises of even better ones one day. Maybe. But I definitely find myself leaning towards a library purely to keep the doors as wide open as possible to function effect evolution.

4 Likes

Thanks for the thoughtful feedback and for weighing in on the library-vs-stdlib question!

Your concern about future language evolution is a strong argument for taking the swiftlang-org library path over standard library inclusion.

The macro-generated code introduces no new semantics or compiler behavior, so the surface area for conflicts with future language features is the same as the surface area that exists today when developers write paired sync/async overloads manually. Keeping the macros out of the standard library means they can evolve (or be superseded) on their own timeline without the compatibility constraints that come with standard library inclusion. I agree with you there.

On the linked Sendable issue, were you raising it as a specific concern related to the macros, or as a general example of the complexities around Swift's concurrency model? If there's a specific case where the macros could trip over that issue, I'd want to understand it better.

2 Likes

Thanks for the suggestion!

Swift's naming convention for macros consistently uses capitalization. For example, @Observable, @Model, and @Entry. The lowercase ones like @available and @inlinable are language-level attributes, so they follow a different convention.

That said, if the language steering group or the community has a strong preference for lowercase to emphasize the parallel with language-level keywords like throws and async, I'm open to it.

1 Like

My first thought about reasync was not "remove async", which is a problem. However, I think a macro that duplicates a function is very useful and powerful, immediately leading me to think of something like @duplicate(remove: [.async]), which, in my opinion, reads nicer with clearer intentions and could evolve to support other function signature modifications (generics, typed throws, parameter value types, sendability, escapability, etc).

The first 3 mentioned are from SwiftUI and SwiftData, which are proprietary to Apple. Modern Swift macros are written with the symbol prefix # or @ before its name, depending on its context (expression or attached). The naming typically starts lowercased (off the top of my head, the only library I know that uses uppercase as the default is swift-testing).

1 Like

Thanks for the feedback!

On @duplicate(remove: [.async]), I considered generalizing beyond @Reasync and ultimately don't think it fits. The async-to-sync transformation works because async and await are purely annotational when the body doesn't depend on inherently asynchronous APIs like actors. The result is guaranteed to be semantically equivalent to the hand-written version.

Most other function-signature modifications don't have this property. Removing throws from the signature isn't mechanical, since there's no well-defined answer for what happens to the throw statements. Changing generic types or parameter types produces a different function, not a duplicate of the original.

That doesn't mean a general duplication macro couldn't exist, but I'd want to see a common use case for each transformation before committing to the generalized design. For now, @Reasync addresses a specific, common, mechanical problem with clear semantics, and I'd rather not trade that clarity for a speculative API.

On naming, @Observable is part of the Swift standard library (SE-0395), not Apple-proprietary. Beyond Swift Testing's @Test and @Suite, capitalization for attached macros is the established pattern across community packages as well, like pointfreeco/swift-composable-architecture's @Reducer, @ObservableState, @Presents, and @ViewAction.

Ultimately, I'm open to whatever the LSG decides, but I wanted to add clarity to the naming convention for future readers.

1 Like

Just on the topic of “reasync” being a strange name for something that removes async, there’s always the option of @deasync…

(not proposed in full earnest, but the serendipity was enough to compel me to post)

7 Likes

This is very much like a macro I wrote for my own library, in which I called it @DeAsync. Mine looks like this:

@attached(peer, names: overloaded)
internal macro DeAsync(replacing oldTypes: [Any.Type] = [],
                       with newTypes: [Any.Type] = [],
                       stripSendable: StripSendable = .none)

oldTypes and newTypes are for when you have types, such as callback typealiases, that need to be swapped out. The stripSendable parameter allows for stripping Sendable from the attached function, its parameters, or both.

4 Likes

Right, it was just backwards compatibility I was thinking about. And again, I'm not saying there is a problem, only that I'm finding it hard to determine there would definitely not be. Further, if it did end up in the standard library and some kind of syntactic/semantic change landed that mattered, I have to imagine the macro could either just be fixed or different behavior gated behind a feature flag.

No, sorry, that issue wasn't a specific example of a problem. It was a more general illustration of how, even after an proposal was done to define all possible async function conversion behavior, it still was not 100% clear in all cases.

This kind of functionality feels very similar to function conversion to me, which is why I thought of it. If we're hunting for edge cases, I'd start by looking at isolated parameters, particularly when they appear both in the function and function argument. I bet we can find function types where this kind of transformation will not be possible.

But that's also probably fine. A construct like this doesn't have to work in every possible scenario, as long as it is useful in some.

1 Like

This is a really cool way to do this.

Personally, I don’t really see the need for @ReasyncMembers though. I think the need to put the @Reasync macro on each function makes the meaning more clear. It would be pretty easy to overlook @ReasyncMembers on the type while implementing an async function with a closure that needs to be async, and it would be frustrating to debug if you didn’t know it was there.

As for the naming, Reasync only really makes sense if you know rethrows, which is slowly being replaced in the stdlib with typed throws. Deasync highlights the wrong thing. The fact that the function is duplicated without async is not really important. It means that its async-ness depends on the async-ness of the parameter. Maybe something along the lines of @ConditionallyAsync?

5 Likes

Disclaimer: this is feedback from the person who implemented such a functionality using sed, of all tools (before macros were even an option), so take it for what it's worth, and not as coming from a particularly authoritative place in terms of the swiftiness of the solution…

On the contribution: this is undoubtedly valuable, in particular this seems to use Swift Syntax as straightforwardly as possible, such that this ought not break when unrelated additions are made to the Swift language syntax; and yet even then it requires quite a bit more code than my one-line sed command (with 3 substitution expressions), showing this task to be anything but trivial. Hence, this body of code is probably best handled by the community for when it inevitably needs to evolve with the times.

On the concept: as a reminder to this audience, the whole concept of implementing "reasync" as a macro is a stopgap at best, because of intrinsic limitations of what a macro can do: operating at the level of the AST, such a macro is unable to itself fully perform the kind of semantic validation that is to be expected of such a feature. For instance, no @Reasync macro that operates on the AST will ever catch that there is actually no way to generate a valid sync version of the combiner function here (sort of reimplementing significant compiler frontend functionality itself):

func b() async throws -> UInt
{
    try await Task.sleep(nanoseconds: 1000);

    return 3;
}

@Reasync func combiner(_ a: () async ->UInt) async throws -> UInt
{
    return try await a() + b();
}

cf the discussion concluding at A case study for `reasync` - #36 by Zollerboy1

On the diagnostics: the whole concept of "reasync", modeled on rethrows, is that the marked function has the async effect only as a result of at least one of its nominal parameters being itself a function having the async effect (if its async effect is unrelated to its parameters, then it is not possible to call any version of it in a sync way, unless there is no await or equivalent in there, in which case the function does not need to be async itself in the first place!); and this is indeed the case with the usages of @Reasync in SwiftTestKit, where that parameter is named either body or property. Therefore, it would be better for the macro itself to catch situations early where it is meant to remove an await (or the async part of an async let, etc.), but no such parameter is found in the expression covered by the await; doing so would catch common oversights where async calls were added in that function in places unrelated to its parameter(s), and nothing complained so far since that was legal, up to now.

(As it stands, this diagnostic will be raised when the compiler will process the generated AST, at which points its context will be 100% generated code, which the user will need to reverse engineer as a prerequisite to figuring out where they went wrong; when macros were introduced I was under the impression they are responsible for performing this kind of diagnostic rather than let their generated code fail downstream, but I may have missed some developments in the meantime).

On the @ReasyncMembers macro: I myself have had no need for it, as none of the relevant functions in my code are members functions, but I can definitely picture the need for it.

On the commitment that this represents: blessing this macro would not commit the Swift community (besides the commitment to maintain this macro) any more than the existing practice of spelling out two versions of the relevant functions, one with async and await removed from it; and there is evidence this practice was already spreading as far back as 2022: The latest information on `reasync`? . That ship has sailed.

On the possibility of a more general macro that could remove any subset of the syntax: this would be really damaging to the possiblity to improve diagnostics by catching errors earlier as part of macro expansion. If the macro does not even know the meaning of what it is removing, it would not be able to help diagnose cases where the removal turns out to be ill-advised.

On the name proper: "reasync", while modeled on rethrows, is not as grammatically sound as its model if only to the extent async is not a verb. Which is the reason why I don't mind a stopgap functionality keeping that name, so the better eventual name can be reserved for future evolution such as the hoped for built-in language feature.

4 Likes

Good call on the isolated-parameter case. I was curious so I tested it, and the reproducer is close to what you described:

@Reasync
func run(
    isolation   : isolated (any Actor)? = #isolation,
    _ body      : (isolated (any Actor)?) async throws -> Void
) async rethrows
{
    try await body(isolation)
}

The macro expands and compiles cleanly. But calling either the sync or async version of this function produces an error:

func caller() async throws
{
    try await MainActor.run
    {
        let called: Bool = false
        
        // Pattern that the region-based isolation checker does not understand how to check. Please file a bug
        try run(isolation: MainActor.shared)
        {
            _ throws in
            
            _ = called
        }
    }
}

The trigger appears to be a closure capturing a local variable from the enclosing isolated context being passed into the function. It appears both in sync and async forms. I filed this as #88627.

Most importantly for this context, the error is unrelated to the macro's async-to-sync transformation. The same error reproduces identically on a hand-written sync function with the @Reasync macro and the async/await keywords removed.

I think this is actually a good confirmation of the proposal's position. The macro's transformation is purely syntactic, so the generated peer is equivalent to a hand-written declaration. Any edge case in the generated code is an edge case in the equivalent hand-written Swift.

1 Like

Thanks!

Yeah, @Reasync is the core, and @ReasyncMembers is more of a convenience. I didn't end up using @ReasyncMembers in swift-test-kit myself. If it's seen as scope bloat by the community and the LSG, I'd be comfortable dropping it.

I would lean towards removing it. It seems to me to be an attractive nuisance, because the impact isn't felt in the codebase, but it would have the effect of creating many, possibly dozens of in pathological cases, unnecessary methods that the compiler may not be able to optimize out for one reason or another. (e.g. if the methods are public API of a framework.)

1 Like

Thanks for the detailed response!

This is an interesting idea. To restate what I understand you're suggesting: The macro could check, at expansion time, whether each await in the body is on a call to one of the function's closure parameters, and emit a diagnostic on the source if it isn't, rather than letting the compiler error fire later on generated code.

The fundamental reason the macro doesn't do this is that it operates on syntax, not semantics. rethrows enforcement works because the compiler has full type information and can trace every throw or try back to a closure parameter, but the macro doesn't have that access.

Consider the simplest case, where a syntactic check would work:

@Reasync
func run(
    _ body: () async -> Int
) async -> Int
{
    return await body()
}

This is true reasync-ability: run(_:) is async only because it needs to call body. The macro can see this from the syntactic AST alone.

Now consider:

@Reasync
func run(
    _ body: () async -> Int
) async -> Int
{
    let callback = body
    return await callback()
}

The syntactic check sees await callback() and notes that callback isn't a parameter. Refusing to expand would be wrong, since the function is perfectly reasync-able. The macro would need semantic analysis to reliably determine that callback is bound to body.

Similar problems arise with methods on parameters (await body.someMethod()), passing closures through other constructs, or any number of indirections the compiler handles trivially but a syntax walker can't handle.

A best-effort syntactic check could catch the clearest violations, like an await on a free-standing async function, as in your original example. But it would miss a lot, and would produce false positives on legitimate patterns. It couldn't be a hard enforcement like rethrows.

The good news is that the tooling has caught up here. When a generated overload fails to compile, Xcode and the Swift CLI surface the diagnostic in the macro expansion context, so users don't need to reverse-engineer the source of the error.

For any readers interested in more detail, the full proposal covers this in the Semantic Validity section of Detailed Design.

Love the functionality, hate the name (in line with the discussion above): I would associate @Reasync with rethrows semantics, i.e. the ability for higher-order functions similar to map() to take either sync or async functions/closures, not the removal of async.

I agree here. @Deasync +1

Please correct me if I’m wrong, but I think there is a bit of confusion here.

Originally, the name reasync came directly from the need for a rethrows equivalent for async. The idea being that a function’s async-ness could be generic and depend on the input’s async-ness. So, while the implementation of this macro literally makes a copy of the function without async, the meaning is different. It does not mean “remove async”. It means that the function is only async if the input is async. Also, Deasync just sounds like it removes async, which is not true. It makes a copy that is not async to allow the function to only be async sometimes.

As for the need for a proper function effect, personally, I also see no point in making a reasync effect on functions into a language feature. The only mark that this implementation misses is the ability to produce useful diagnostics in all cases. rethrows is also being slowly replaced with typed throws, so it would stop matching eventually.

I think a better long-term solution is to allow async to be determined through generics. This would match typed throws and it would allow more expressive APIs while being far less magical than reasync. For example:

func withSomething<
    let isAsync: Bool,
    E: Error,
    T: ~Copyable
>(
    _ body: (Something) async(isAsync) throws(E) -> T
) async(isAsync) throws(E) -> T {
    let something = …
    return try await body(something)
}

Here, the compiler can specialize this function for every combination of async, return type, and error type. await here also still means the same thing: a potential suspension point. This requires Bool to be supported for value generics as well though.

For now though, this macro provides all the same functionality as well.

6 Likes

This is starting to go beyond my level of familiarity. But I think what you have written there is a form of a value-dependent type. Which, while very cool, it something that has been called out as being desirable to avoid in at least one proposal (SE-0431).

(if I am understanding right, which I may not be)