Contents

SE-0533: Generating synchronous overloads of `async` functions with a macro

Summary of changes

This proposal adds an @Reasync macro that generates a synchronous overload of an async function, 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 typically 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. Swift compiler engineer Doug 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; 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 mechanical duplication, then the duplication can be generated by a macro.

Proposed solution

This proposal adds the @Reasync macro either to the Swift standard library or as an official package in the swiftlang GitHub organization. The choice of venue is left open. Either path provides the canonical, shared solution that library authors currently lack.

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 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(
    _       values      : [Int],
    using   transform   : (Int) async -> Int
) async -> Int
{
    var total: Int = 0

    for value in values
    {
        total += await transform(value)
    }

    return total
}

// Generated by @Reasync:
//
// func sum(
//     _       values      : [Int],
//     using   transform   : (Int) -> Int
// ) -> Int
// {
//     var total: Int = 0
//
//     for value in values
//     {
//         total += transform(value)
//     }
//
//     return total
// }

Aside from the concurrency annotations covered in Strict concurrency, all 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.

Returning to the motivating example, @Reasync eliminates the duplication in swift-test-kit with 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
}

16 declarations across swift-test-kit's property-based testing API collapse to 8, with no possibility of drift between sync and async overloads.

Detailed design

For a full working implementation, please see swift-reasync.

Macro declarations

A new macro is introduced:

@attached(peer, names: overloaded)
public macro Reasync()

The macro is declared as introducing overloaded names because the generated peer has the same name as the source function, differing only in the signature changes required to make it a valid synchronous declaration. Swift's overload resolution distinguishes the two declarations at each call site, selecting the synchronous peer in synchronous contexts and selecting the asynchronous source in asynchronous contexts.

Transformation

The macro walks the function's syntax tree and removes the async and await keywords wherever they appear, along with the related concurrency annotations that are either invalid on synchronous forms or that the peer's synchronous body can no longer support. All other syntax is preserved.

The macro removes:

  • The async effect specifier in the function signature.
  • The async effect specifier in any closure parameter types,

including deeply-nested specifiers.

  • Each await keyword, preserving the inner expression in place.
  • The async modifier on async let bindings, producing ordinary

let bindings.

  • The await keyword in for await loops, including for try await.
  • The @Sendable attribute on closure types appearing in the

function's parameter clause, at any depth. Closure types appearing in body positions are not affected (for example, local binding type annotations).

  • The @isolated(any) attribute on closure parameter types appearing

in the function's parameter clause, at any depth. Closure types appearing in body positions are not affected, as with @Sendable.

  • The @concurrent attribute on the function declaration and on

closure parameter types.

  • The nonisolated(nonsending) modifier on the function declaration

and on closure parameter types.

The rationale for each of these removals is detailed in Strict concurrency.

Nesting

The transformation is applied recursively throughout the function to which the macro is attached. Every nested declaration that the function body contains (nested function declarations, closure expressions, and computed property accessors) and every nested function type that appears in the body have their async tokens, await tokens, concurrency annotations, and async let/for await constructs rewritten in the same way as the outer function. This is necessary for the generated synchronous peer to compile, since any async token, await token, or concurrency annotation left untransformed in a nested position would be invalid in the synchronous context of the peer.

Because nested function declarations are transformed by the enclosing macro, attaching @Reasync directly to a nested function has no additional effect. The macro emits a warning at the redundant attribute, along with a fix-it to remove it. See Diagnostics.

Local computed properties

Local computed properties declared inside the body of an @Reasync function may have async accessors. The transformation applies to these accessors as it does to nested function declarations: the async effect specifier is removed from the accessor's signature, and the body is rewritten in the same way as the outer function. This is necessary for the generated synchronous peer to compile, since the body's accesses to the property are rewritten to remove await, and would otherwise be invalid against an async accessor.

@Reasync is not supported on a computed property declaration. Although the macro's transformation is well-defined for an async accessor, the result cannot participate in the language's overload resolution: two property declarations with the same name and type at the same scope are an invalid redeclaration, not an overload, regardless of async.

Strict concurrency

The macro's transformation removes async and await from the function declaration, but real-world async functions in Swift 6 frequently carry additional annotations. The macro handles these as follows:

| Annotation | Rule | Removal Scope | |---------------------------|----------|------------------------------------------------| | async, await | Remove | Everywhere | | @isolated(any) | Remove | Closure parameter types (at any nesting depth) | | nonisolated(nonsending) | Remove | Everywhere | | @concurrent | Remove | Everywhere | | @Sendable | Remove | Closure parameter types (at any nesting depth) | | sending | Preserve | | | Global actors | Preserve | | | isolated parameters | Preserve | | | Bare nonisolated | Preserve | |

The rules are designed to produce a synchronous peer that is type-correct under Swift 6 strict concurrency in the cases the macro is intended to handle, without silently changing the meaning of annotations that have nothing to do with concurrent execution.

@isolated(any)

@isolated(any) is removed from closure types in the function's parameter clause. The annotation is allowed on synchronous function types, so in positions outside the function's parameter clause (for example, a local binding's type annotation in the body), the macro preserves it.

SE-0431 specifies the rule that governs calls to @isolated(any) function values:

Since the isolation of an @isolated(any) function value is statically unknown, calls to it typically cross an isolation boundary. This means that the call must be awaited even if the function is synchronous...

SE-0431 also describes an exception to this rule: calls that do not cross an isolation boundary because the caller is isolated to a derivation of the function's .isolation. This exception relies on language mechanisms (for example, isolated captures on closure expressions) that cannot be expressed by a function declaration's signature. The synchronous peer that the macro generates is a function declaration; its isolation is fixed by global actor attributes, an isolated parameter, or nonisolated, and cannot be made dependent on a derivation of a parameter's .isolation property. Any call to the closure parameter in the peer's body therefore crosses an isolation boundary and requires await.

However, a synchronous function body cannot contain await. The peer must therefore omit @isolated(any) to compile in the common case that the macro is intended to handle.

nonisolated(nonsending)

nonisolated(nonsending) is removed wherever it appears. SE-0461 defines nonisolated(nonsending) as an annotation on async functions:

Async functions annotated with nonisolated(nonsending) will always run on the caller's actor.

The compiler accordingly rejects nonisolated(nonsending) on synchronous functions and on synchronous function types. Since the generated peer is always synchronous, the annotation is removed unconditionally.

@concurrent

@concurrent is removed wherever it appears. The annotation is the counterpart to nonisolated(nonsending) under SE-0461 and is subject to the same restriction:

@concurrent cannot be applied to synchronous functions.

The restriction extends to synchronous function types as well. Since the generated peer is always synchronous, the annotation is removed unconditionally.

@Sendable

@Sendable is removed from closure types in the function's parameter clause. Unlike the annotations above, @Sendable remains legal on a synchronous function: the language does not reject it, and a hand-written synchronous overload could retain it without a compile error. The macro removes it since, in the common case, an @Sendable constraint on a closure parameter is present because the async function body sends the closure across an isolation boundary (for example, by passing it into a child task via async let or TaskGroup). The generated peer eliminates these constructs and invokes the closure in-place, in the caller's isolation, so the @Sendable requirement is no longer needed for the peer's body to compile. The Language Steering Group identified this behavior in its evaluation of this proposal:

...at least in this case, that @Sendable annotation could be filtered from the generated synchronous variant since the parallel execution is eliminated.

@Sendable on a closure type that appears elsewhere in the source is preserved (for example, on a local binding's type annotation in the function body). The macro's transformation eliminates parallel execution arising from async let and for await constructs in the function body, but does not transform Task-based concurrent execution that the body may also contain. An @Sendable annotation in body position may still be required by the body's own constructs, and the macro preserves it rather than risk producing a peer that fails strict concurrency checking.

This default fits the common case, but is not universal. The Language Steering Group flagged this directly:

[Filtering the @Sendable annotation] may not necessarily be a universally applicable part of the transform.

Determining whether @Sendable remains necessary on the synchronous peer would require the macro to analyze what the function body does with the closure: whether the closure is sent across an isolation boundary, captured by a Task, shared with another concurrent context, and so on. The macro operates on syntax alone and cannot perform this analysis. Its only options are to always remove @Sendable or to always preserve it.

The macro always removes it. Always preserving @Sendable would silently over-constrain callers in cases where the macro's transformation has already eliminated the parallel execution that motivated the annotation. Always removing @Sendable instead surfaces potential misalignments as a compile error, where the user can write the synchronous overload by hand.

There are cases where this default does not match the author's intent or the body's actual needs, and the macro cannot distinguish them from the syntax alone. When this happens, the generated peer fails strict-concurrency checking, and the compiler surfaces the diagnostic in the macro expansion. There is no silent miscompilation. The author writes the synchronous overload by hand, where the type system applies the same checks to an ordinary declaration. The asynchronous source can remain @Reasync-free in that case. The macro is intended to eliminate the mechanical duplication that arises in the common case; it is not intended to express every possible relationship between an async function and its synchronous counterpart.

sending

sending is preserved on parameters and on return types. Unlike the annotations above, sending does not describe how a function executes; it describes a property of the values that flow across the function's boundary. SE-0430 makes this explicit through an example in which sending appears on a fully synchronous method:

@MainActor
struct S {
  let ns: NonSendable

  func getNonSendable() -> sending NonSendable {
    return NonSendable() // okay
  }
}

The sending annotation on this synchronous method permits the caller to send the returned value across an isolation boundary, exactly as it would for an async function with the same return type. The annotation's meaning is independent of whether the function is async.

The same reasoning applies to sending parameters. A sending parameter expresses that the caller's region is split at the call site, allowing the callee to send the value into an opaque region. This applies whether the callee is synchronous or asynchronous. SE-0430 specifies:

A sending function parameter requires that the argument value be in a disconnected region. At the point of the call, the disconnected region is no longer in the caller's isolation domain, allowing the callee to send the parameter value to a region that is opaque to the caller.

Isolation

The macro preserves the function's isolation. Global actor attributes such as @MainActor, bare nonisolated modifiers, and isolated parameters carry over to the peer unchanged. These annotations describe properties that are independent of whether the function is async. SE-0461 draws this distinction directly:

nonisolated functions will have consistent execution semantics by default, regardless of whether the function is synchronous or asynchronous.

The same holds for global actor isolation and isolated parameters: they describe where the function runs, not whether it suspends. Because the peer's static isolation matches the source's, a call to the peer that originates from a context with the same isolation does not cross an isolation boundary, and the sendability rules that govern the source's parameters and results apply identically to those of the peer.

The dynamic-isolation distinctions that SE-0461 introduces for async functions, between nonisolated(nonsending) and @concurrent, are not expressible on synchronous functions, as described in the preceding nonisolated(nonsending) and @concurrent sections. Bare nonisolated carries no such constraint and applies to synchronous and asynchronous functions equally; it is preserved on the peer without modification.

Overload resolution

The generated synchronous declaration has the same name, generic signature, parameter list, and return type as the annotated function. With async removed from the signature, it qualifies as an overload of the source under SE-0296, which permits two declarations to differ only in async and specifies the resolution rule for selecting between them:

Given a call, overload resolution prefers non-async functions within a synchronous context (because such contexts cannot contain a call to an async function). Furthermore, overload resolution prefers async functions within an asynchronous context (because such contexts should avoid stepping out of the asynchronous model into blocking APIs).

The overload-resolution rule depends on the synchronous or asynchronous context, in which the compiler selects one and only one overload.

The macro relies on this existing rule and introduces no new resolution behavior.

Diagnostics

If @Reasync is attached to a synchronous function declaration, or to a declaration that is not a function, the macro emits the following error:

'@Reasync' can only be applied to async functions

If @Reasync is attached to a function declaration that is nested within another @Reasync-attributed function, the macro emits the following warning at the redundant attribute:

Nested function declarations within an '@Reasync' function are already transformed by the enclosing macro

And a fix-it is offered to remove the attribute.

If @Reasync is attached to a function requirement within a protocol, the macro emits the following error:

'@Reasync' cannot be applied to protocol requirements

See Protocol requirements.

Semantic validity

The transformation walks the syntax tree of the function declaration, and does not inspect or have access to the semantics of the function body. After macro expansion, the compiler determines whether the generated synchronous declaration is valid at the usual semantic analysis stage. If the body contains constructs that are inherently asynchronous, such as calls to actor-isolated methods or calls to async-only APIs, the generated overload will fail to compile, and the compiler will report the error at the site of the invalid expression in the expanded source.

This ensures at compile time that the macro cannot silently produce a synchronous function that diverges in meaning from the asynchronous source.

Because the transformation is purely syntactic, the generated synchronous overload is an ordinary Swift declaration: the compiler applies the same parsing, type-checking, and isolation-checking rules to the generated peer that it would apply to any other declaration in the source file. The macro introduces no new constructs that the compiler needs to recognize, and contributes no behavior at run time.

An inherent consequence of operating on the syntax tree is that the macro cannot enforce the discipline that a language-level reasync would. A true language feature, modeled on rethrows, would require that the function's async effect arise only from its closure parameters, and the compiler would reject any function where this is not the case. The macro has no comparable enforcement, since determining whether a given await corresponds to a closure parameter requires semantic analysis the macro cannot perform. A function that calls an async-only API independently of its parameters can therefore be marked @Reasync and expand successfully; the compile error appears afterward, when the generated peer is type-checked. The error will identify the invalid expression in the expanded source, but the macro itself cannot flag the misuse at the attachment site. See Diagnosing misuse syntactically at the expansion site.

Any property of the generated peer declaration (whether it compiles, what diagnostics it produces, how it interacts with isolation checking, and so on) is fundamentally a property of the equivalent hand-written declaration. The macro does not introduce new edge cases or limitations beyond those that may already exist in synchronous Swift.

Trivia preservation

The transformation is designed to preserve the source's formatting in the generated peer. Trivia attached to removed tokens is transferred to a nearby meaningful token rather than being discarded, so that whitespace, source comments, and documentation comments survive the transformation in their original positions.

When the source declaration is nested inside another declaration's body, the macro normalizes the peer's indentation to its attachment site so that the peer renders at the same depth as the source.

Grammar and parsing

The macro introduces no new syntax. It is applied using the existing attribute syntax and requires no changes to the Swift grammar or parser.

Source compatibility

This proposal is purely additive. It introduces a new macro declaration and does not modify any existing language features, standard library APIs, or parsing rules. Existing code continues to compile and behave exactly as before.

The macro name Reasync occupies the attribute namespace, but attribute names do not conflict with identifiers in other namespaces. Code that uses Reasync as a type name, function name, or variable name is unaffected.

ABI compatibility

The macro has no ABI impact of its own. It expands at compile time to an ordinary Swift function declaration, and the ABI of each generated declaration is exactly that of the equivalent hand-written synchronous function. Existing compiled code is unaffected.

Implications on adoption

The macro is implemented entirely at compile time via SwiftSyntax, and requires no runtime support.

Adopting @Reasync in a library is a source-compatible change for the library's clients, since the macro only introduces new synchronous overloads alongside the existing asynchronous declarations. Clients in asynchronous contexts continue to resolve to the original asynchronous declarations, and clients in synchronous contexts gain access to the newly-generated overloads.

Adopting @Reasync for a function whose hand-written synchronous overload previously required @Sendable on a closure parameter is a source-compatible change, since callers that previously satisfied @Sendable will continue to satisfy the now-unconstrained parameter. See Strict concurrency for when this default behavior is or isn't appropriate.

Removing @Reasync from a declaration whose generated synchronous overload is in use by clients is a source-breaking change, since the synchronous overload is no longer generated. Library authors should therefore treat the synchronous overload as part of the library's public API once adopted, in the same way as any other API.

Neither adopting nor removing the macro affects ABI compatibility, since the macro expands to an ordinary function declaration at compile time.

Future directions

Protocol requirements

The @Reasync transformation already works syntactically on protocol requirements. However, the generated synchronous requirement is not currently handled correctly by the compiler in dispatch through protocol existentials, causing runtime crashes (see swiftlang/swift#89397). The macro rejects this attachment site to prevent the crash. Once the underlying compiler issue is resolved, an amendment to this proposal could remove the rejection and support protocol requirements; no further changes to the transformation are needed.

Subscripts

@Reasync could in principle support async subscripts, since subscript declarations participate in the same overload-resolution rules as functions. This direction is separable from the core proposal and could be pursued in a follow-up proposal.

Preserving @isolated(any) if isolation expressivity grows

The Strict concurrency section explains why the macro removes @isolated(any) from closure types appearing in the function's parameter clause: a function declaration's signature cannot currently express isolation to a derivation of a parameter's .isolation property, so the synchronous peer cannot validly call an @isolated(any) closure parameter. SE-0431 explores the idea that this limitation may not be permanent:

It is currently not possible for a local function or closure to be isolated to a specific value that isn't already the isolation of the current context.

SE-0431 goes on to describe how value-specific isolation might interact with @isolated(any) under a future proposal such as the closure isolation control pitch. If a comparable mechanism is later extended to function declarations, the macro could be revised to preserve @isolated(any) on closure parameters and generate a peer whose isolation is expressed in terms of one of those parameters. This would be a behavioral change for callers of the synchronous overload, who would gain the dynamic isolation contract that the asynchronous source already provides. Pursuing this direction is contingent on language-level support that does not yet exist. Until then, removing @isolated(any) from the peer's parameter clause is the only approach that produces a valid synchronous declaration.

Alternatives considered

A language-level reasync

The most direct alternative to this macro is a reasync keyword that mirrors rethrows, allowing a single function declaration to be synchronous or asynchronous depending on the async-ness of its closure parameters. This direction was sketched in SE-0296, and has been discussed periodically in the years since.

A language feature would offer an advantage that a macro cannot: a single symbol serving both calling contexts. If that were the only consideration, the language feature would be the better design. But several arguments favor the macro over the language feature on its own merits: efficiency, applicability, evolvability, and implementation.

Efficiency: The polymorphism gains of reasync are smaller than rethrows

The principal polymorphism benefit of rethrows does not transfer to reasync.rethrows is efficient because the ABI of throwing and non-throwing functions is designed to share a single entry point, so one compiled function serves both calling contexts. Synchronous and asynchronous functions, by contrast, have fundamentally different calling conventions. SE-0296 notes this directly:

The ABI of throwing functions is intentionally designed to make it possible for a rethrows function to act as a non-throwing function, so a single ABI entry point suffices for both throwing and non-throwing calls. The same is not true of async functions, which have a radically different ABI that is necessarily less efficient than the ABI for synchronous functions.

The Language Steering Group reiterated this point in their evaluation of this proposal, observing that any reasync implementation would need to emit two separate machine-level functions, mirroring what the proposed macro already produces at the source level.

Applicability: reasync is less broadly applicable than rethrows

Following rethrows, the reasync model assumes that the synchronous and asynchronous variants of a function should have the same implementation, differing only in the propagation of an effect. This assumption holds for many throwing APIs, but holds far less often for asynchronous ones. SE-0296 acknowledges this with the example of Sequence.map, where the right asynchronous implementation is not a sequential await in a loop, but a concurrent one that processes elements in parallel:

For something like Sequence.map that might become concurrent, reasync is likely the wrong tool: overloading for async closures to provide a separate (concurrent) implementation is likely the better answer. So, reasync is likely to be much less generally applicable than rethrows.

The Language Steering Group made the same observation in their evaluation of this proposal:

async code offers many more possibilities for semantic distinctions between synchronous and async variations of a function, such as different interactions with isolation and sendability, or different parallel execution strategies that aren't readily available in synchronous code, so it isn't as clear-cut that there is a one-size-fits-most solution like rethrows for async.

The recommendation in SE-0296 for cases like this is to write two declarations: a synchronous one and an asynchronous one with a tuned implementation. This is the pattern the proposed macro produces. The macro handles the common case where the two implementations would be identical apart from async, await, and the annotations that depend on them.

Evolvability: The macro affords easier evolution at both layers

A function annotated with @Reasync can later be replaced by a hand-written synchronous overload without affecting source compatibility or ABI. The Language Steering Group identified this as a specific advantage of the macro:

Being a macro, developers could even evolve their code by removing the macro and switching to a separately-written synchronous variant if necessary, without disturbing API or ABI, which would not necessarily be possible starting from a single reasync declaration.

A language-level reasync declaration, by contrast, would bind the synchronous and asynchronous forms of a function together at the language level. Splitting them later would be a source-breaking change for clients that have come to depend on the two overloads being a single symbol.

The Language Steering Group also noted that the macro form is more amenable to incremental development:

A macro potentially offers more flexibility to evolve in response to unanticipated needs.

A macro is a library-level artifact. Its behavior can be refined, its diagnostics improved, and new variants introduced over time without amending the language. A language feature, by contrast, becomes part of Swift's stable surface and is correspondingly difficult to revise after introduction.

Implementation: reasync would also be implemented with a macro-like mechanism

The same calling-convention asymmetry that limits the polymorphism gains of a language-level reasync also shapes how such a feature would necessarily be implemented. The Language Steering Group described this in their evaluation of this proposal:

Even if we pursued a more integrated reasync type system solution in the style of rethrows, it would need to have a macro-like underlying implementation, generating two separate machine-level functions, since synchronous and async functions cannot share a calling convention.

Since the duplication exists either way, the question reduces to where the feature is implemented. The macro places it at the source level, where the generated peer is an ordinary Swift declaration subject to the compiler's standard parsing, type-checking, and isolation-checking rules. A language feature would place the same duplication inside the compiler. The source-level placement carries no efficiency penalty, and gains the diagnostic and evolvability properties described in the preceding subsections.

The macro is therefore not a stopgap pending a language-level reasync. It is the preferred approach to the problem on the basis of efficiency, applicability, evolvability, and implementation. A future reasync proposal remains possible, but its acceptance is not a prerequisite for solving the common case the macro addresses, and the macro's design choices would remain defensible even alongside such a feature.

Naming

This proposal suggests the name @Reasync for the following reasons:

  1. "reasync" is the name that many Swift developers have reached for

when discussing this feature over the years.

  1. The feature occupies the same mental slot as rethrows: a way for a

function's effect to be conditional on its closure parameters. The macro's mechanism differs (it removes async rather than conditionally reintroducing it), but the name does not describe the mechanism; it describes the user-facing outcome: just as rethrows describes a function that exists in both throwing and non-throwing forms, "reasync" describes a function that exists in both synchronous and asynchronous forms.

  1. Attached macros within the standard library and third-party

libraries consistently use title-case names (for example, Swift Testing's @Test and @Suite, and pointfreeco/swift-composable-architecture's @Reducer, @ObservableState, @Presents, and @ViewAction). Lowercase attributes such as @available and @inlinable are reserved for language-level features and follow a different convention.

"reasync" has appeared in many community discussions over the years:

<details> <summary>Community discussions and proposals using "reasync"</summary>

| Post | Date | |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------| | Pondering about a future with async/await | September 2018 | | SE-0296: Async/await | December 2020 | | [[Pitch #2] Structured Concurrency](https://forums.swift.org/t/pitch-2-structured-concurrency/43452/116) | January 2021 | | Pitch: Fix rethrows checking and add rethrows(unsafe) | February 2021 | | Exploration: Type System Considerations for Actor Proposal | February 2021 | | Pitch #6 Actors | March 2021 | | Request to amend AsyncSequence | July 2021 | | SE-0338: Clarify the Execution of Non-Actor-Isolated Async Functions | January 2022 | | Swift project focus areas in 2023 | November 2022 | | The latest information on reasync? | December 2022 | | A case study for reasync | April 2023 | | SE-0395: Observability | April 2023 | | Algebraic Effects | June 2023 | | New function colour: unsafe | June 2023 | | [[GSoc 2024] Improving keyword completion in SwiftSyntax](https://forums.swift.org/t/gsoc-2024-improving-keyword-completion-in-swiftsyntax-initial-approach-discussion/70432/3) | March 2024 | | SE-0443: Precise Control Flags over Compiler Warnings | August 2024 | | How to avoid cascading async functions? | September 2024 | | Async/Await: is it possible to start a Task on @MainActor synchronously? | December 2024 | | Blocking await! | June 2025 | | Borrow and Inout types for safe, first-class references | February 2026 |

</details> <br/>

Alternative names for this feature have been suggested, and include the following:

  • @reasync: The lowercase version of the proposed name.
  • @duplicate: Generalizes to a family of transformations beyond

async. See A generalized duplication macro.

  • @deasync/@DeAsync: Emphasizes that the macro removes async,

rather than reintroducing it from closure parameters. See A parameterized type-replacement macro.

  • @ConditionallyAsync: Describes the resulting overload set rather

than the transformation.

A generalized duplication macro

Community feedback in the pitch thread suggested generalizing the macro into an @duplicate(remove: [...]) form that could strip arbitrary syntactic features beyond async, such as throws, @Sendable, escapability, or generic parameters. Under this design, @Reasync would be one preset of a more general transformation, and library authors could compose their own duplications by selecting which annotations to remove.

This proposal does not pursue that direction. The async/await transformation works because async and await are purely annotational over a function body that does not depend on inherently-asynchronous APIs. When those tokens are removed, the resulting function is semantically equivalent to a hand-written synchronous version, by construction. The macro can guarantee this property because the transformation is well-defined.

Most other function-signature modifications do not share this property:

  • Removing throws from a function signature is not mechanical, since

there's no well-defined answer for what happens to the throw statements.

  • Removing generic parameters or changing parameter types

produces a different function, not a duplicate of the original.

  • Removing @Sendable has different implications depending on whether

the macro also eliminates the parallel execution that motivated the annotation in the first place. This question, and the decision to handle it with a fixed default rather than a configurable parameter, are addressed in Strict concurrency and A parameterized type-replacement macro.

Each of these transformations carries its own semantic concerns that a single generalized macro would either have to encode separately, or leave to the user to navigate.

A general-purpose duplication macro could plausibly exist, but each transformation it supports would need its own design rationale and its own discussion of when the result remains semantically equivalent to the source. Combining them under one attribute would not preserve that distinction. This proposal addresses the specific, common, mechanical case of async-to-sync duplication with semantics that can be stated precisely. A broader design is better pursued in a separate proposal where each supported transformation can be argued on its own merits.

A parameterized type-replacement macro

An example of this direction was raised in the pitch thread. The exemplified @DeAsync macro extends the async-to-sync transformation with additional parameters that replace types in the function signature. The exemplified macro accepts arrays of source and replacement types, allowing call-site type aliases and callback signatures to be substituted during expansion.

This proposal does not pursue that direction. The @Reasync macro generates an overload of the source function: a peer with the same name, generic signature, and parameter list. Replacing types in the signature produces a function that is no longer an overload of the source, but a separate function with a related shape. A peer macro that generates a non-overload is a different conceptual operation from one that generates an overload.

The stripSendable parameter of the exemplified macro raises a separate question about how the generated synchronous overload should interact with strict concurrency. That question is addressed in Strict concurrency, where the macro takes a fixed default rather than a configurable one.

A fixed default fits this proposal's intent. The macro's value comes from being a single, mechanical decision: a function is either a candidate for @Reasync or it is not. Per-annotation configuration would shift that decision from the macro's design into each adoption site, transferring the question of whether the generated peer is correct from the macro's authors to the macro's users. The user would gain control, but would also gain responsibility for verifying the result against strict concurrency on a case-by-case basis, which is the same responsibility they would have when hand-writing the overload, but now with the additional indirection of a macro expansion.

When the macro is the wrong tool, the preferred approach is to write the synchronous overload by hand. This applies whether the generated peer would not actually be an overload of the source, or the macro's defaults are wrong for a particular function. The type system checks the hand-written work directly, and the reasoning that justifies the hand-written overload lives in the source alongside it. A configurable or type-replacing macro would require the same per-function reasoning, distributed across annotations at each adoption site and resolved at expansion time rather than in the source.

Generating async overloads from synchronous sources

The proposed macro generates a synchronous overload from an asynchronous source, but not the reverse. A symmetric macro could in principle generate an asynchronous overload from a synchronous source by inserting async and await keywords. An example of this direction was raised in the pitch thread.

This proposal does not pursue that direction. Two considerations favor the async-to-sync direction: it is the wider context, and the syntactic transformation is reliable in only one direction.

async is the wider context

A synchronous function can be called from an asynchronous context, but an asynchronous function cannot be called from a synchronous context. The asynchronous form carries strictly more information than the synchronous form: it identifies the points at which suspension may occur. Removing async and await discards information that was already present, producing a more constrained version of the same function, while inserting async and await requires the macro to introduce information that the source did not contain. The async-to-sync direction is lossless, while the sync-to-async direction is generative.

The syntactic transformation is reliable in only one direction

A synchronous function body does not generally identify which calls should become asynchronous. A purely syntactic macro cannot determine where to insert await without semantic information about which expressions resolve to async functions. Without that information, the macro must either insert await indiscriminately (producing invalid code) or rely on the user to mark the relevant call sites explicitly (transferring the work back to the user that the macro was supposed to save).

A future proposal could explore sync-to-async duplication. Its design space is shaped by the asymmetry between the two directions and warrants separate consideration.

Diagnosing misuse syntactically at the expansion site

The macro could attempt a best-effort syntactic check at expansion time, refusing to expand (or emitting a warning) when an await in the body does not appear to correspond to a call on one of the function's closure parameters. This would surface diagnostics on the original source rather than on generated code, more closely approximating the experience a language-level reasync would provide.

This proposal does not include such a check. The macro operates on the syntax tree and has no access to type information or name resolution. The cases where a syntactic check would suffice are a small subset of legitimate uses of @Reasync.

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 is not 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 cannot handle. A check strict enough to be sound would reject legitimate patterns, while a check loose enough to accept them would miss most of the cases it was meant to catch.

The compiler's downstream type-checking is a more reliable enforcement mechanism, even though it produces diagnostics on generated code rather than on the source.

A member-iterating companion macro

A companion macro applied to a type or extension could generate synchronous overloads for every async member declaration in one annotation, rather than requiring @Reasync on each member individually. The transformation itself would be unchanged; only the iteration surface would be new. This direction was raised during the pitch and is separable from the core proposal. This proposal does not pursue it.

An implementation of this direction is available as @ReasyncMembers at swift-reasync.

Keeping the macro as a third-party package

The macro is currently published as a third-party package, swift-reasync. Leaving it there indefinitely is one possible outcome.

The case against this outcome is that the problem this macro solves is common across Swift libraries that support both synchronous and asynchronous calling contexts. Leaving the solution in a third-party package means that each library that adopts such a macro either takes on a dependency on one particular package, or duplicates the implementation. In the latter case, the Swift ecosystem ends up with multiple incompatible implementations of the same transformation, with inconsistent naming, semantics, and diagnostics. Either outcome fragments the solution.

Promoting the macro to a canonical location, either within the Swift standard library or as an official package in the swiftlang GitHub organization, avoids this fragmentation and signals that this macro is the recommended solution to the problem.

A sed script or code generator

An example of this direction was shared on the Swift Forums, noting that the transformation can be accomplished with a sed script that rewrites an asynchronous source file into a synchronous one at build time.

This approach works, but has significant drawbacks compared to a macro:

  • It operates on text rather than a syntax tree, and cannot distinguish

async as a keyword from async appearing in an identifier or comment.

  • It requires a build step external to the Swift compiler, and is not

portable across platforms.

  • It produces a separate source file that must be committed or

regenerated, rather than a compile-time expansion.

  • It cannot emit diagnostics on invalid or problematic code.

A macro-based solution addresses all of these concerns by operating on the Swift AST and integrating with the compiler's existing expansion and diagnostic infrastructure.

Preserving @Reasync on the generated peer

The peer macro could in principle leave @Reasync on the generated synchronous declaration. This would have the advantage of making the relationship between the source declaration and generated declaration visible in the expanded code.

This proposal removes @Reasync from the peer. The macro would otherwise re-trigger on the generated declaration, producing infinite expansion, or require special-case handling in the macro expansion logic to suppress re-triggering. Removing the attribute is the simpler and more robust choice, and the relationship between the source declaration and generated declaration remains evident from the expansion itself.

Revision history

The following changes were made to this proposal after the pitch discussion, in response to feedback from the Language Steering Group:

  • Added an extended discussion of language-level reasync as a true

alternative, with arguments for why this proposal favors the macro approach.

  • Added discussion of alternative macro implementations raised in the

pitch thread.

  • Added a "Strict concurrency" section covering the macro's handling

of @Sendable, @isolated(any), nonisolated(nonsending), @concurrent, and sending.

  • Gathered the naming alternatives raised in the pitch thread into the

"Alternatives considered" section.

  • Updated the proposal title to be more descriptive of what the macro

does.

  • Separated @ReasyncMembers from the proposal.

The implementation was also extended as follows:

  • The macro now implements handling of the concurrency-related

annotations covered by strict concurrency.

  • The macro now recursively transforms nested function declarations,

closure expressions, and accessors of local computed properties within the annotated function.

  • The macro now refuses to expand on protocol function requirements and

emits a diagnostic at the attachment site.

  • The macro now normalizes indentation when attached to functions

nested inside another declaration's body, so that the generated peer is indented to its attachment site.

  • Trivia preservation was expanded and refined.
  • The test suite was expanded substantially, with tests running under

the NonisolatedNonsendingByDefault upcoming feature in Swift 6 language mode and under thread, undefined-behavior, and address sanitizers.

Cited proposals

Acknowledgments

Thank you to ZPedro for the Swift Forum thread A case study for reasync, Doug Gregor for the observation in that thread that a peer macro could plausibly cover the common case, and Konrad Malawski for encouraging this proposal.