Swift (peer) macros

Macros are a new Swift feature, part of the Swift standard library (currently available with Xcode 15 beta) to generate repetitive code at compile time.

A macro will add new code alongside the code that you wrote, but never modifier or deletes code that’s already part of your project.

Calling a macro is pretty straightforward and most developers are already familiar with the syntax: @Observable is a macro.

Developers can now create their own macros and leverage the great power of this feature.

Swift has two kinds of macros:

We’ll be focusing on a specific kind of attached macros: the peer macro.

A peer macro adds new declarations alongside the declaration it’s applied to.

In Expand on Swift macros WWDC2023 video, Apple provides a great example on how to use a peer macro to generate a completionHandler variant of an async/await function.

The AddCompletionHandler macro will automatically generate a callback-based function at compile time.

@AddCompletionHandler(parameterName: "onCompletion")
func fetchAvatar(_ username: String) async -> Image?
    {…}
}

// Generated code
func fetchAvatar(_ username: String, onCompletion: @escaping (Image?) -> Void) {
    Task.detached {
         onCompletion(await fetchAvatar(username)
    }
}

Why not try to go the other way around and generate an async/await variant for our callback-based functions ?

Many codebases don’t rely yet on the new Swift concurrency system (async/await), wouldn’t it be great to be able to generate such functions while keeping support of the old concurrency system ?

Let’s write a custom peer macro by first conforming to the PeerMacro protocol which will force us to implement the following static function:

public struct AsyncPeerMacro: PeerMacro {
    public static func expansion(of node: AttributeSyntax,
                                 providingPeersOf declaration: some DeclSyntaxProtocol,
                                 in context: some MacroExpansionContext) throws -> [DeclSyntax] {
        {...}
    }
}

What we will implement next in our expansion function relies strongly on the inspection of the initial callback-based function using a po command in the console. This will print out everything we need to know about our function, in the form of a tree.

FunctionDeclSyntax
├─attributes: AttributeListSyntax
│ ╰─[0]: AttributeSyntax
│   ├─atSignToken: atSign
│   ╰─attributeName: SimpleTypeIdentifierSyntax
│     ╰─name: identifier("AddAsync")
├─funcKeyword: keyword(SwiftSyntax.Keyword.func)
├─identifier: identifier("fetchData")
├─signature: FunctionSignatureSyntax
│ ╰─input: ParameterClauseSyntax
│   ├─leftParen: leftParen
│   ├─parameterList: FunctionParameterListSyntax
│   │ ╰─[0]: FunctionParameterSyntax
│   │   ├─firstName: identifier("completion")
│   │   ├─colon: colon
│   │   ╰─type: FunctionTypeSyntax
│   │     ├─leftParen: leftParen
│   │     ├─arguments: TupleTypeElementListSyntax
│   │     │ ╰─[0]: TupleTypeElementSyntax
│   │     │   ╰─type: SimpleTypeIdentifierSyntax
│   │     │     ╰─name: identifier("String")
│   │     ├─rightParen: rightParen
│   │     ╰─output: ReturnClauseSyntax
│   │       ├─arrow: arrow
│   │       ╰─returnType: SimpleTypeIdentifierSyntax
│   │         ╰─name: identifier("Void")
│   ╰─rightParen: rightParen
╰─body: CodeBlockSyntax
  ├─leftBrace: leftBrace
  ├─statements: CodeBlockItemListSyntax
  │ ╰─[0]: CodeBlockItemSyntax
  │   ╰─item: FunctionCallExprSyntax
  │     ├─calledExpression: IdentifierExprSyntax
  │     │ ╰─identifier: identifier("completion")
  │     ├─leftParen: leftParen
  │     ├─argumentList: TupleExprElementListSyntax
  │     │ ╰─[0]: TupleExprElementSyntax
  │     │   ╰─expression: StringLiteralExprSyntax
  │     │     ├─openQuote: stringQuote
  │     │     ├─segments: StringLiteralSegmentsSyntax
  │     │     │ ╰─[0]: StringSegmentSyntax
  │     │     │   ╰─content: stringSegment("Hello")
  │     │     ╰─closeQuote: stringQuote
  │     ╰─rightParen: rightParen
  ╰─rightBrace: rightBrace
  

To create an async/await variant of our function, we require only two informations from the function tree.

public enum AsyncDeclError: CustomStringConvertible, Error {
    case onlyApplicableToFunction
    case onlyApplicableToFunctionWithASingleFunctionArgument
    
    public var description: String {
        switch self {
        case .onlyApplicableToFunction:
            "@AddAsync can only be applied to a function."
        case .onlyApplicableToFunctionWithASingleFunctionArgument:
            "@AddAsync can only be applied to a function with the following signature: func someMethodName(someCompletionName: (someType) -> Void)"
        }
    }
}

public struct AsyncPeerMacro: PeerMacro {
    public static func expansion(of node: AttributeSyntax,
                                 providingPeersOf declaration: some DeclSyntaxProtocol,
                                 in context: some MacroExpansionContext) throws -> [DeclSyntax] {
        
        guard let function = declaration.as(FunctionDeclSyntax.self) else {
            throw AsyncDeclError.onlyApplicableToFunction
        }
        
        guard function.signature.input.parameterList.count == 1,
              let functionCompletionParameter = function.signature.input.parameterList.first?.type.as(FunctionTypeSyntax.self),
              let functionCompletionParameterType = functionCompletionParameter.arguments.first?.type.as(SimpleTypeIdentifierSyntax.self) else {
            throw AsyncDeclError.onlyApplicableToFunctionWithASingleFunctionArgument
        }
        
        return [DeclSyntax(stringLiteral: """
        func \(function.identifier.text)() async -> \(functionCompletionParameterType.name) {
            await withCheckedContinuation { continuation in
               \(function.identifier.text) { value in
                   continuation.resume(with: .success(value))
               }
            }
        }
        """)]
    }
}

By parsing (and properly type casting) our tree, we are able to to retrieve these informations and construct the DeclSyntax struct which is basically our async/await output function in the form of a string literal.

One last step before testing our macro implementation is to expose our macro using the following syntax:

@attached(peer, names: overloaded)
public macro AddAsync() = #externalMacro(module: "someModuleNameWithYourMacros", type: "AsyncPeerMacro")

Let’s now implement our test using XCTestCase.

let testMacros: [String: Macro.Type] = [
    "AddAsync": AsyncPeerMacro.self
]

final class AsyncMacroTests: XCTestCase {
    func testAsyncMacro() {  
        assertMacroExpansion(
            """
            @AddAsync
            func fetchData(completion: (String) -> Void) {
                completion("Hello")
            }
            """, expandedSource: """
            func fetchData(completion: (String) -> Void) {
                completion("Hello")
            }
            func fetchData() async -> String {
                await withCheckedContinuation { continuation in
                   fetchData { value in
                       continuation.resume(with: .success(value))
                   }
                }
            }
            """, macros: testMacros)
    }
}

assetMacroExpansion requires an input which is our callback-based function annotated with our macro @AddAsync and the output should be the newly generated async/await function variant.

Beware of the lines indentation otherwise test case will fail (one extra space will result in a failing test).

Because macros are generated at compile time, Xcode will emit errors to guide us towards correct usage and ensure we build reliable macros.

Let’s test our AddAsync macro with a concrete example.

class Webservice {
    @AddAsync
    func fetch(completion: (Data) -> Void) {
        completion(Data())
    }
}

AddAsync should generate an async/await function variant.
As mentioned earlier, expanded source will be hidden however we can inspect it by simply right clicking on @AddAsync > Expand Macro

func fetch() async -> Data { // @AddAsync
    await withCheckedContinuation { continuation in
       fetch { value in
           continuation.resume(with: .success(value))
       }
    }
}

Finally, let’s call both variants in our code.

let webService = Webservice()

webService.fetch { data in
    print(data)
}

Task {
    let data = await webService.fetch()
    print(data)
}

Codebase using the old Swift concurrency system can now use the @AddAsync attribute to automatically generate the proper async/await variant.

Our code is purposely oversimplified to demonstrate what a great tool Swift macros can be when it comes to updating a codebase with modern techniques while keeping support of the old implementation.

For instance, when building an SDK, developers might want to keep callback-based functions while enabling clients to use the modern async/await approach.

Note: Swift macros are currently supported with Xcode beta 2 (build fails with Xcode beta 3)

Thanks for reading!