How to Create Swift Macros with Xcode 15
Discover the new Swift Macro, speed up your workflow and make your code easier to read. Available for Swift 5.9 and Xcode 15.
• 8 min read
Availability
Swift 5.9
What are Macros?
Swift Macros allow you to generate repetitive code at compile time, making your app's codebase more easier to read and less tedious to write.
There are two types of macros:
- Freestanding macros stand in place of something else in your code. They always start with a hashtag (#) sign.
#caseDetection // Freestanding Macro
- Attached macros are used as attributes on declarations in your code. They start with an @ sign.
@CaseDetection // Attached Macro
Create new Macro
Macros need to be created in a special Package
that depends on swift-syntax
library.
Note
SwiftSyntax is a set of Swift libraries for parsing, inspecting, generating, and transforming Swift source code. Here is the GitHub repo.
To create a new Macro go to New -> Package
and select Swift Macro
.
Type the name of your Macro and create the Package
.
Note
Type only the actual name of the macro, without Macro suffix. Eg. for a Macro named AddAsync, type AddAsync not AddAsyncMacro.
Macro Package structure
Inside the newly created Package
you will find some auto-generated files:
[Macro name].swift
where you declare the signature of your Macromain.swift
where you can test the behaviour of the Macro[Macro name]Macro.swift
where you write the actual implementation of the Macro[Macro name]Tests.swift
where you write the tests of the Macro implementation
Macro roles
A single Macro can have multiple roles that will define its behaviour.
The available roles are:
@freestanding(expression)
Creates a piece of code that returns a value.
Protocol
ExpressionMacro
Declaration
@freestanding(expression)
@freestanding(declaration)
Creates one or more declarations. Like struct
, function
, variable
or type
.
Protocol
DeclarationMacro
Declaration
@freestanding (declaration, names: arbitrary)
@attached(peer)
Adds new declarations alongside the declaration it's applied to.
Protocol
PeerMacro
Declaration
@attached(peer, names: overloaded)
@attached(accessor)
Adds accessors to a property. Eg. adds get
and set
to a var
. For example the @State
in SwiftUI.
Protocol
AccessorMacro
Declaration
@attached(accessor)
@attached (memberAttribute)
Adds attributes to the declarations in the type/extension it's applied to.
Protocol
MamberAttributeMacro
Declaration
@attached(memberAttribute)
@attached (member)
Adds new declarations inside the type/extension it's applied to. Eg. adds a custom init()
inside a struct
.
Protocol
MemberMacro
Declaration
@attached(member, names: named(init()))
@attached(conformance)
Adds conformances to protocols.
Protocol
ConformanceMacro
Declaration
@attached(conformance)
Build Macro
Signature
In this guide we will create a Macro that creates an async
function off of a completion
one.
To start building this Macro we need to create the Macro signature.
To do this, go to [Macro name].swift
file and add.
@attached(peer, names: overloaded)
public macro AddAsync() = #externalMacro(module: "AddAsyncMacros", type: "AddAsyncMacro")
Here you declare the name of the Macro (AddAsync
), then in the #externalMacro
you specify the module it is in and the type of the Macro.
Implementation
Then to implement the actual Macro, go to the [Macro name]Macro.swift
file.
Create a public struct
named accordingly with the name of the Macro and add conformance to protocols based on the signature you specified in the Macro signature
.
So, inside newly created struct
and add the required method.
public struct AddAsyncMacro: PeerMacro {
public static func expansion(of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
// Implement macro
}
}
If your Macro signature
has more than one role you need to add conformance to each role, for example:
// Signature
@attached(accessor)
@attached(memberAttribute)
@attached(member, names: named(init()))
public macro // ...
// Implementation
public struct MyMacro: AccessorMacro, MamberAttributeMacro, MemberMacro { }
Note
To know the corresponding protocols see Macro roles section.
Exporting the Macro
Inside [Macro name]Macro.swift
file add or edit this piece of code with to newly created Macro.
@main
struct AddAsyncMacroPlugin: CompilerPlugin {
let providingMacros: [SwiftSyntaxMacros.Macro.Type] = [
AddAsyncMacro.self
]
}
Expansion method
The expansion method is responsible for generating the hidden code.
Here the piece of code the Macro (declaration
) is attached on, is broken into pieces (TokenSyntax
) and manipulated to generate the desired additional code.
To do this we have to cast the declaration
to the desired syntax.
Eg. If the Macro can be attached to a struct
we will cast it to StructDeclSyntax
.
In this case the Macro can only be attached to a function
so we will cast it to FunctionDeclSyntax
.
So, inside the expansion
method add:
guard let functionDecl = declaration.as(FunctionDeclSyntax.self) else {
// TODO: Throw error
}
return []
Now, before we continue, we need to write a test that checks whether the implementation of the Macro generates the code we expect.
So, in [Macro name]Tests.swift
file add:
func test_AddAsync() {
assertMacroExpansion(
"""
@AddAsync
func test(arg1: String, completion: (String?) -> Void) {
}
""",
expandedSource: """
func test(arg1: String, completion: (String?) -> Void) {
}
func test(arg1: String) async -> String? {
await withCheckedContinuation { continuation in
self.test(arg1: arg1) { object in
continuation.resume(returning: object)
}
}
}
""",
macros: testMacros
)
}
Once that is in place, let's add a breakpoint at return []
inside the expansion
method and run the test.
Once we hit the breakpoint, run po functionDecl
inside the debug console to get this long description:
FunctionDeclSyntax
├─attributes: AttributeListSyntax
│ ╰─[0]: AttributeSyntax
│ ├─atSignToken: atSign
│ ╰─attributeName: SimpleTypeIdentifierSyntax
│ ╰─name: identifier("AddAsync")
├─funcKeyword: keyword(SwiftSyntax.Keyword.func)
├─identifier: identifier("test")
├─signature: FunctionSignatureSyntax
│ ╰─input: ParameterClauseSyntax
│ ├─leftParen: leftParen
│ ├─parameterList: FunctionParameterListSyntax
│ │ ├─[0]: FunctionParameterSyntax
│ │ │ ├─firstName: identifier("arg1")
│ │ │ ├─colon: colon
│ │ │ ├─type: SimpleTypeIdentifierSyntax
│ │ │ │ ╰─name: identifier("String")
│ │ │ ╰─trailingComma: comma
│ │ ╰─[1]: FunctionParameterSyntax
│ │ ├─firstName: identifier("completion")
│ │ ├─colon: colon
│ │ ╰─type: FunctionTypeSyntax
│ │ ├─leftParen: leftParen
│ │ ├─arguments: TupleTypeElementListSyntax
│ │ │ ╰─[0]: TupleTypeElementSyntax
│ │ │ ╰─type: OptionalTypeSyntax
│ │ │ ├─wrappedType: SimpleTypeIdentifierSyntax
│ │ │ │ ╰─name: identifier("String")
│ │ │ ╰─questionMark: postfixQuestionMark
│ │ ├─rightParen: rightParen
│ │ ╰─output: ReturnClauseSyntax
│ │ ├─arrow: arrow
│ │ ╰─returnType: SimpleTypeIdentifierSyntax
│ │ ╰─name: identifier("Void")
│ ╰─rightParen: rightParen
╰─body: CodeBlockSyntax
├─leftBrace: leftBrace
├─statements: CodeBlockItemListSyntax
╰─rightBrace: rightBrace
Here you can see every component of the function declaration.
And now you can pick the individual piece you need and use it to create you Macro-generated code.
Retrieve first argument name
For example, if you need to retrieve the first argument name of the function, you will write:
let signature = functionDecl.signature.as(FunctionSignatureSyntax.self)
let parameters = signature?.input.parameterList
let firstParameter = parameters?.first
let parameterName = firstParameter.firstName // -> arg1
This is quite a long code just to retrieve a single string and it will be even more complex if you need to handle multiple function variations.
I think that Apple will improve this in the future, but for now let's stick with this.
Complete the implementation
Now, let's complete the AddAsync
implementation.
if let signature = functionDecl.signature.as(FunctionSignatureSyntax.self) {
let parameters = signature.input.parameterList
// 1.
if let completion = parameters.last,
let completionType = completion.type.as(FunctionTypeSyntax.self)?.arguments.first,
let remainPara = FunctionParameterListSyntax(parameters.removingLast()) {
// 2. returns "arg1: String"
let functionArgs = remainPara.map { parameter -> String in
guard let paraType = parameter.type.as(SimpleTypeIdentifierSyntax.self)?.name else { return "" }
return "\(parameter.firstName): \(paraType)"
}.joined(separator: ", ")
// 3. returns "arg1: arg1"
let calledArgs = remainPara.map { "\($0.firstName): \($0.firstName)" }.joined(separator: ", ")
// 4.
return [
"""
func \(functionDecl.identifier)(\(raw: functionArgs)) async -> \(completionType) {
await withCheckedContinuation { continuation in
self.\(functionDecl.identifier)(\(raw: calledArgs)) { object in
continuation.resume(returning: object)
}
}
}
"""
]
}
In this block of code we:
- Retrieve the completion argument from the function signature
- Parse the function arguments except the completion
- Create the arguments that get passed into the called function
- Compose the async function
Show custom errors
Macros allow you to show custom errors to the user.
For example, in case the user placed the macro on a struct
but that macro can only be used with functions
.
In this case, you can throw an error and it will be automatically shown in Xcode.
enum AsyncError: Error, CustomStringConvertible {
case onlyFunction
var description: String {
switch self {
case .onlyFunction:
return "@AddAsync can be attached only to functions."
}
}
}
// Inside expansion
method.
guard let functionDecl = declaration.as(FunctionDeclSyntax.self) else {
throw AsyncError.onlyFunction // <- Error thrown here
}
Test Macro usage
To test the behaviour of the AddAsync
macro.
Go to the main.swift
file and add:
struct AsyncFunctions {
@AddAsync
func test(arg1: String, completion: (String) -> Void) {
}
}
func testing() async {
let result = await AsyncFunctions().test(arg1: "Blob")
}
As you can see the build completes with success.
Warning
The autocompletion may not show the generated async function.
Show Macro generated code
To expand a Macro in code and see the automatically generated code, right click
on the Macro and choose Expand Macro
from the menu.
Warning
The Expand Macro
seems to not work always in Xcode 15.0 beta (15A5160n).
Breakpoint
Code generated by Macros can be debugged by adding breakpoints as you normally would.
To do this, right click
on a Macro and choose Expand Macro
from the menu.
Then add a breakpoints at the line you wish to debug.
Conclusion
Congratulations! You just created your first Macro.
Check out here the complete code.
As you saw, so far Macro implementations can be quite long event to perform a simple task.
But once you wrap your head around, they can be really useful and they can save you a lot of boilerplate code.
Macros are still in Beta so I think Apple will improve them by the time they will be available publicly.
If you have any question about this article, feel free to email me or tweet me @franceleonidev and share your opinion.
Thank you for reading and see you in the next article!
Share this article