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:
- 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!