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:
reasyncis in a tricky place because the design is easy (just followrethrowsbut withasync), 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
asyncand non-asyncversions. 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 anasyncfunction withasyncclosure parameters, produces a synchronous version of that function that zaps theasyncfrom closure parameters as well as all of theawaits 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:
- swiftlang/swift#88174 (Linker error for nested type in peer macro expansion) - Pending fix #88827
- swiftlang/swift#88175 (Symbol graph omits symbols generated by peer macro expansions) -Pending fix #88898
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?