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.

Francesco Leoni

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 Macro
  • main.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:

  1. Retrieve the completion argument from the function signature
  2. Parse the function arguments except the completion
  3. Create the arguments that get passed into the called function
  4. 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

Related articles


FLCharts: Create Bar Chart easily

Create beautiful and highly customisable bar, line, radar charts and many more with FLCharts, a flexible, easy-to-use library.

3 min read

ChartsSwift

Make Code Easier to Write and Read in Swift 5.9

Improve your code with inline if-else and switch statements available in Swift 5.9. Make your codebase less tedious to write.

3 min read

Swift

Tutorial: How to Create a Timed Paging Carousel Like Instagram

Learn how to create a page controller that automatically changes page with the new UIPageControlTimerProgress component.

4 min read

SwiftUIKit