Swift (peer) macros

Jullian Mercier
4 min readJul 9, 2023

--

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:

  • Freestanding (e.g `#warning`)
  • Attached (`@Observable`)

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.

— The name of the function (e.g. `fetchData`)

— The callback single argument type which is a String (e.g. `(String) -> Void`)

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!

--

--

Jullian Mercier
Jullian Mercier

Written by Jullian Mercier

Senior iOS engineer. jullianmercier.com. @jullian_mercier. Currently looking for new job opportunities.

No responses yet