Streaming is available in most browsers,
and in the Developer app.
-
Write Swift macros
Discover how you can use Swift macros to make your codebase more expressive and easier to read. Code along as we explore how macros can help you avoid writing repetitive code and find out how to use them in your app. We'll share the building blocks of a macro, show you how to test it, and take you through how you can emit compilation errors from macros.
Chapters
- 1:15 - Overview
- 5:10 - Create a macro using Xcode's macro template
- 10:50 - Macro roles
- 11:40 - Write a SlopeSubset macro to define an enum subset
- 20:17 - Inspect the syntax tree structure in the debugger
- 24:35 - Add a macro to an Xcode project
- 27:05 - Emit error messages from a macro
- 30:12 - Generalize SlopeSubset to a generic EnumSubset macro
Resources
Related Videos
WWDC23
-
Download
♪ ♪ Alex Hoppen: Who likes writing repetitive boilerplate code? Nobody does! And that's why in Swift 5.9 we are introducing Swift macros. Swift macros allow you to generate that repetitive code at compile time, making your app's codebases more expressive and easier to read. My name is Alex Hoppen, and today I am going to show you how you can write your own macros. I will start off by giving you a short overview of how macros work.
Afterwards, we will jump straight into Xcode and see how you can create your first macro. Having seen our first macro in Xcode, we will explore more roles in which you can use macros and I will show you how I used macros to simplify the codebase of an app I am currently working on.
Finally, I will show you how macros can communicate errors or warnings back to the compiler if they are not applicable in a certain context. So let's get started. Here we have a list of calculations that first-year students can use to practice their arithmetic skills. We have the result as an integer on the left and the calculation as a string literal on the right side of the tuple. Notice how this is repetitive, redundant, and even error prone because nobody can guarantee that the result actually matches the calculation. Luckily, with Swift 5.9 we can define a stringify macro to simplify this. This macro also happens to be the one that is included in Xcode's template. The stringify macro only takes the calculation as a single parameter. At compile time it expands to the tuple that we saw before, guaranteeing that the calculation and the result match. So how does this work? Let's take a look at the definition of the macro itself. Notice that it looks a lot like a function. The stringify macro takes an integer as the input parameter and outputs a tuple containing an the result, an integer, and the calculation-- a string. If the arguments of the macro expression don't match the macro's parameters, or don't type check by themselves, the compiler will emit an error without applying the macro expansion. For example, if I pass a string literal to this macro, the compiler complains that 'String' is not convertible to expected argument type 'Int'. This is different to, for example, C macros, which are evaluated at the pre-processor stage before type-checking. But it allows us to use all the powers that you know and love from Swift functions, like being able to make your macro generic.
Also note that this macro is declared with the freestanding expression macro role. This means that you can use the macro wherever you can use an expression, and that it will be indicated by the hash character, like we see with #stringify. Other kinds of macros are attached macros that can augment declarations. I will cover those later. After checking that all the arguments match the macro's parameters, the compiler performs the macro expansion. To see how that works, let's focus on a single macro expression.
To perform the expansion, each macro defines its implementation in a compiler plug-in. The compiler will send the source code of the entire macro expression to that plug-in. The first thing that the macro plug-in does, is to parse the source code of the macro into a SwiftSyntax tree. This tree is a source-accurate, structural representation of the macro, and it will be the basis on which the macro operates. For example, our 'stringify' macro is represented in the tree as a macro expansion expression node. That expression has the macro name 'stringify'. And it takes a single argument, which is the infix operator plus applied to 2 and 3. What's really powerful about Swift macros is that that the macro's implementation is itself a program written in Swift and can perform any transformation to the syntax tree it wants. In our case, it generates a tuple like we saw before. It will then serialize the generated syntax tree into source code again and send it to the compiler, which will replace the macro expression by the expanded code.
That's really cool, but now I want to understand how all of this actually looks like in code. The new macro template in Xcode defines the stringify macro that we have just seen. Let us walk through that template and explore the macro's definition, how the expansion works, and how the macro can be tested. To create the template, I click File, New, Package, and now I select the Swift Macro template.
Let's call our first macro "WWDC".
So what do we get with the template? Here we have an invocation of the #stringify macro, similar to what we have seen before. It takes a parameter "a + b" and returns the result, as well as the code that produced it. If I want to know what the macro expands to, I can right-click on it and select Expand Macro.
That's exactly what we saw before. But how is the macro defined? Let's jump to its definition.
Here we have a slightly generalized version of our previous 'stringify' macro. Instead of taking an integer, this macro is generic and can receive any type T.
The macro is declared as an external macro.
This tells the compiler that to perform the expansion, it needs to look at the StringifyMacro type in the WWDCMacros module.
How is that type defined? Let's take a closer look at it. Because stringify is declared as a freestanding expression macro, the StringifyMacro type needs to conform to the ExpressionMacro protocol.
This protocol has a single requirement: The expansion function. It takes the syntax tree of the macro expression itself, as well as a context that can be used to communicate with the compiler.
The expansion function then returns the rewritten expression syntax.
What does it do in the implementation? At first, it retrieves the single argument to the macro expression. It knows that this argument exists because stringify is declared as taking a single parameter and all arguments need to type-check before the macro expansion can be applied. It then uses string interpolation to create the syntax tree of a tuple. The first element is the argument itself and the second is a string literal containing the source code of the argument.
Notice that the function is not returning a string here. It is returning an expression syntax. The macro will automatically invoke the Swift parser to transform this literal into a syntax tree. And because it is using the literal interpolation style for the second argument, it will make sure that the literal's contents are properly escaped. Nobody likes bugs. But what I like even less are bugs in code that I don't see unless I explicitly ask for it by expanding the macro. That is why you want to make sure that your macro is well-tested. Because macros don't have side effects and the source code of syntax trees is easy to compare, a great way to test them is to write unit tests. The macro template already comes with one.
This test case uses the 'assertMacroExpansion' function from the SwiftSyntax package to verify that the 'stringify' macro expands correctly.
It takes the '#stringify(a + b)' expression, that we saw before, as input. and asserts that after the macro is expanded, it produces a tuple containing 'a + b' and the string literal 'a + b'.
To tell the test case how to expand the macros, it passes the 'testMacros' parameter, which specifies that the macro ‘#stringify' should be expanded using the 'StringifyMacro' type. Let's run the tests in the same way that you might already run the tests of your app, to see if they indeed pass.
The tests pass, and with that, we already have our first macro.
In it, we saw its basic building blocks. The macro declaration defines the macro's signature. It also declares the macros roles. The compiler plug-in performs the expansion. It is a program that is itself written in Swift and operates on SwiftSyntax trees.
We also saw that macros are very testable because they are deterministic transformations of syntax trees and the source code of syntax trees is easy to compare. So you might wonder, "In which other situations can we use macros?" We have already seen a freestanding expression macro. Just to recap, this macro is spelled with a hash and allows you to rewrite the entire macro expression. There's also a freestanding declaration role that expands to a declaration instead of an expression. The other kinds of macros are attached macros. These are spelled with an @, just like attributes, and allow the macro to augment the declaration they are attached to. For example, an attached member macro adds new members of the type it is attached to. To learn more about these other roles, I highly recommend watching "Expand on Swift macros" where Becca goes over them in great detail. But I want to focus on the attached member role because it helped me improve the codebase of an app I am currently working on. I am also a ski instructor, and recently I have been working on an app that allows me to plan the tours I want to take my students on. One thing you absolutely want to avoid as a ski instructor is to take beginners on slopes that are too hard for them. I want to use the Swift type system to enforce that. That's why, in addition to the Slope enum that contains all the slopes in my favorite ski resort, I also have an EasySlope type that only contains slopes suitable for beginners. It has an initializer to convert a slope to an easy slope, if the slope is indeed easy, and a computed property to convert an easy slope back to a general slope.
While this provides great type safety, it is really repetitive. If I want to add an easy slope, I need to add it to Slope...
EasySlope, the initializer, and the computed property. Let's see if we can improve things using a macro. What we want to do is to automatically generate the initializer and the computed property. How can we do this? Both the initializer and the computed property are members of the EasySlope type, so we need to declare an attached member macro.
Next, we will create the compiler plug-in that contains the macro's implementation. To make sure that our macro behaves as expected, we want to develop it in a test-driven way. Thus, we will leave its implementation empty until we write a test case for it.
After we defined the macro's behavior in a test case, we will write the implementation to match that test case.
And finally, we will integrate the new macro into my app. If all goes well, we will be able to remove the initializer and have the macro generate it for us.
To develop the macro, we work with the template that we created earlier. And since I don't really need the ‘#stringify' macro in my app, I have already removed it. I start by declaring a new attached member macro by using the '@attached(member)' attribute.
I call it SlopeSubset because EasySlope is a subset of Slope.
The macro also defines the names of the members it introduces.
In this demo, I will just be showing you how to generate the initializer. Generating the computed property is very similar, because it's also just a switch statement that switches over all the cases. With this declaration, we have defined the macro, but we have not implemented the expansion that it actually performs. For this, our macro references the SlopeSubsetMacro type in the WWDCMacros module . Let us go and create that type so we can continue to the really exciting part: The actual macro implementation. Since we declared SlopeSubset as an attached member macro, the corresponding implementation needs to conform to the MemberMacro protocol. This protocol has a single requirement: The 'expansion' function, similar to ExpressionMacro.
The 'expansion' function takes the attribute with which we apply the macro to a declaration, as well as the declaration that the macro is being applied to. In our case, this will be the EasySlope enum declaration.
The macro then returns the list of all the new members it wants to add to that declaration.
I know that it's very tempting to start implementing this transformation straight away, but we agreed that we wanted to start by writing a test case for it. So for now, let us just return an empty array, indicating that no new members should be added.
Finally, we need to make SlopeSubset visible to the compiler. For this, I add it to the 'providingMacros' property down here.
Before diving any deeper, I want to make sure that what we have so far works. While I could try applying the macro in Xcode and looking at the expanded code, I much prefer to write a test case for it that I can rerun whenever I'm making changes to the macro, to make sure I'm not introducing regressions.
Just like in the test case in the template, we use the 'assertMacroExpansion' function to verify our macro's behavior.
What we want to test is what the macro generates when applied to the EasySlope type, so we use that as our test case's input.
And since the macro's not doing anything yet, we just expect it to remove the attribute and not add any new members, so the expected expanded code is the same as the input, just without '@SlopeSubset'.
Finally, we need to let the test case know that it should expand the macro SlopeSubset using the SlopeSubsetMacro implementation. For that, we need to map the macro name to its implementing type in the 'testMacros' dictionary and pass that dictionary to the assertion function.
Let's run our tests now to check that what we have written so far actually works.
It does. Great. But we want really wanted is to check that our macro actually generates the initializer, not just remove the attribute. So I'll copy the code that I previously wrote by hand into the test case because really, that's what we want the plug-in to generate.
If we run the test again… ...it fails because our macro doesn't actually generate the initializer yet. Let's change that now.
The initializer switches over all the enum elements declared in the EasySlopes enum. So the first thing that we need to do is to retrieve these enum elements from the declaration. Since enum elements can only be declared inside enum declarations, we start by casting 'declaration' to an enum declaration.
If the macro is attached to a type that is not an enum, we should be emitting an error. I added a TODO so that we don’t forget to do it later, and return an empty array for now. Next, we need to get all the elements that the enum declares. To figure out how to do that, I want to inspect the syntactic structure of our enum in the SwiftSyntax tree.
Since the macro's implementation is just an ordinary Swift program, I can use all the tools that you know from Xcode to debug your programs. For example, I can set a breakpoint inside the expansion function and run the test cases to hit that breakpoint.
We now have the debugger paused inside the macro’s implementation and 'enumDecl' is the EasySlopes enum. We can print it in the debugger by typing 'po enumDecl'.
Let's inspect the output. The innermost nodes of the syntax tree represent the enum elements, the 'beginnersParadise', and 'practiceRun' slopes. To retrieve them, we need to follow the structure that is outlined to us in the syntax tree. Let us walk through that structure step-by-step and write the access code as we go. The enum declaration has a child called 'memberBlock'. This member block contains both the braces and the actual members. So to access the members, we start with 'enumDecl.memberBlock.members'.
These members contain the actual declaration, as well as an optional semicolon. We are interested in the declarations, in particular those declarations that actually declare enum cases. I'm using compact map to get a list of all the member declarations that are enum cases. Each case declaration can declare multiple elements. This is because instead of declaring each slope on a new line after a separate case keyword, I could have written them on the same line as 'case beginnersParadise, practiceRun'.
To retrieve all of them, we can use 'flatMap'.
And now that we have retrieved all the elements, we can start constructing the initializer that we actually want to add to EasySlope.
The initializer declaration has a single item: A switch expression.
This switch expression contains a case for each element in the enum, as well as a default case that returns nil. We need to create syntax nodes for all of these.
Two great ways of finding the syntax nodes to create, are either by printing the syntax tree like we did before, or by reading SwiftSyntax's documentation. We start by constructing an InitializerDeclSyntax.
This type can be constructed by building the body using a result builder and specifying the header-- that is the 'init' keyword and all the parameters. This will allow us to use a for loop inside the result builder to iterate over all the elements, exactly what we need.
I just copy the init header from our test case.
Inside the body, we need a switch expression.
This type also has an initializer that takes a header and a result builder. Let's use it again.
Now we can use the power of result builders by iterating over all elements that we gathered earlier.
For each element, we want to create a new case item, which we can construct using string interpolation just like we saw for ‘#stringify'.
We also need to add a default case that returns nil.
And finally, we can return the initializer.
Let's run the tests to see if we are indeed generating the correct initializer.
We are. So we know that our macro works and we can start using it in my app.
To add our macro package to my Xcode project, I can right-click on it and select "Add Package Dependencies". I can now select the local package that we just created.
To be able to use the macro, I add the WWDC target as a dependency of my app.
We can now import the WWDC module from the package and apply the SlopeSubset macro to the EasySlope type.
… If we build... ...the compiler complains that the hand-written initializer is an invalid redeclaration. And that's because the macro now generates it for us. So we can just delete it.
It's always fun to delete code. Right? So if we want to see what the macro actually generated, we can right-click on SlopeSubset and click Expand Macro.
And if I forgot what the macro does, I can also Option-click on it to read its documentation.
The next step would be to also generate the computed property, but I'll do that later today. By using macros, we were able to get the type safety of EasySlopes without the need to write repetitive code. How did we do that? We started with the Swift macro package template. To explore the syntax tree's structure, we stopped the macro's execution and printed the syntax node inside the debugger. This allowed us to see which properties we needed to access to get all the enum elements.
And it was really easy to develop the macro on its own using a test case. After we added it to my app, it worked straight away. But what happens if your macro is used in situations that it doesn't support? Just like you never want to take a beginner skier onto a difficult slope, you never want to let you macro perform unexpected expansions, or generate code that does not compile. If your macro is used in ways that it doesn't support, always emit error messages that inform your adopters about what's going wrong, instead of having them read the generated code to debug your macro.
In that spirit, let's go and fix the TODO we left in our codebase. When SlopeSubset is applied to a type that is not an enum, the macro should emit an error, saying that it is only applicable to enums. Just like before, let's start by adding a test case.
This time, we are applying the SlopeSubset macro to a struct.
Since there are no enum elements in the struct, we don't expect the macro to generate an initializer. Instead, it should emit a diagnostic, that is an error, informing us that SlopeSubset can only be applied to an enum. If we run this test... ...it fails because we are not outputting the error message yet. Let's go to the compiler plug-in to do so now.
Macro errors can be represented by any type that conforms to the Swift Error protocol. I use an enum with a single case to describe the error message if SlopeSubset is applied to a type that's not an enum.
If we throw the error from the expansion function, it will be shown at the attribute that calls the macro expansion.
If you want to show the error message at a different location than the attribute, generate warnings, or even show Fix-Its in Xcode, there's an 'addDiagnostic' method on the context parameter that allows you to generate rich diagnostics. But I think in this case, it's efficient to just show a simple error message at the attribute. Now, let's see if we did everything right and if our tests pass.
Great, they do. So how does it look like in Xcode if I apply SlopeSubset to a struct? For this, let me copy the test case into a file.
Xcode shows the custom error message inline with all other compilation errors. That makes it easy for adopters of my macro to see what they are doing wrong.
And you know what? Now that we have good error handling, I think this macro might also be useful for other developers specifying enum subsets, not just for slopes. Let's go and generalize it.
To specify the superset of the enum, that we have so far hard-coded as Slope, we add a generic parameter to the macro declaration.
And since the macro is now no longer specific to slopes, let's rename it to EnumSubset by right clicking on SlopeSubset and selecting Refactor, Rename.
I can also choose to rename all occurences inside string literals and comments by Command-clicking them.
We now need to adjust our macro implementation to use the generic parameter, instead of the hard-coded Slopes type. If we print the attribute inside the debugger and inspect its layout, just like we did for 'enumDecl', we can see that we can retrieve the generic parameter by accessing the 'argumentType' of the first argument in the 'genericArgumentClause' of the attribute's name. So now that we've retrieved the generic parameter, we can replace the so-far hardcoded Slope type by the variable 'supersetType'.
I still need to make a couple more changes, like renaming the initializer's parameter, changing the macro implementation's type name, and updating the documentation. I'll do that later. Instead, for now, let's make sure that our tests are still passing.
Since we made EnumSubset generic, we need to explicitly specify that EasySlope is a subset of Slope by passing slope as a generic parameter to the EnumSubset macro.
Let's see if the tests are now passing.
They are. I should really consider publishing this macro to others as a Swift package. So that's a lot of ground we covered today. Let's recap what we went through. To create a macro, you can start with the macro package template, which includes the stringify macro as a great starting point. While developing your macro, we highly encourage you to write test cases to make sure that the code your macro generates is indeed valid. And if you're doing this, you can inspect the layout of the syntax tree by setting a breakpoint in the expansion function, running a test, and printing the syntax tree in the debugger. And finally, if your macro is not applicable in certain circumstances, you should always emit custom error messages so that even if things go wrong, your macro will shine. Thanks for watching, and I'm thrilled to see what kind of macros you will create. ♪ ♪
-
-
5:55 - Invocation of the stringify macro
import WWDC let a = 17 let b = 25 let (result, code) = #stringify(a + b) print("The value \(result) was produced by the code \"\(code)\"")
-
6:31 - Declaration of the stringify macro
@freestanding(expression) public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "WWDCMacros", type: "StringifyMacro")
-
7:10 - Implementation of the stringify macro
public struct StringifyMacro: ExpressionMacro { public static func expansion( of node: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext ) -> ExprSyntax { guard let argument = node.argumentList.first?.expression else { fatalError("compiler bug: the macro does not have any arguments") } return "(\(argument), \(literal: argument.description))" } }
-
9:12 - Tests for the stringify Macro
final class WWDCTests: XCTestCase { func testMacro() { assertMacroExpansion( """ #stringify(a + b) """, expandedSource: """ (a + b, "a + b") """, macros: testMacros ) } } let testMacros: [String: Macro.Type] = [ "stringify": StringifyMacro.self ]
-
12:05 - Slope and EasySlope
/// Slopes in my favorite ski resort. enum Slope { case beginnersParadise case practiceRun case livingRoom case olympicRun case blackBeauty } /// Slopes suitable for beginners. Subset of `Slopes`. enum EasySlope { case beginnersParadise case practiceRun init?(_ slope: Slope) { switch slope { case .beginnersParadise: self = .beginnersParadise case .practiceRun: self = .practiceRun default: return nil } } var slope: Slope { switch self { case .beginnersParadise: return .beginnersParadise case .practiceRun: return .practiceRun } } }
-
14:16 - Declare SlopeSubset
/// Defines a subset of the `Slope` enum /// /// Generates two members: /// - An initializer that converts a `Slope` to this type if the slope is /// declared in this subset, otherwise returns `nil` /// - A computed property `slope` to convert this type to a `Slope` /// /// - Important: All enum cases declared in this macro must also exist in the /// `Slope` enum. @attached(member, names: named(init)) public macro SlopeSubset() = #externalMacro(module: "WWDCMacros", type: "SlopeSubsetMacro")
-
15:24 - Write empty implementation for SlopeSubset
/// Implementation of the `SlopeSubset` macro. public struct SlopeSubsetMacro: MemberMacro { public static func expansion( of attribute: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { return [] } }
-
16:23 - Register SlopeSubsetMacro in the compiler plugin
@main struct WWDCPlugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ SlopeSubsetMacro.self ] }
-
18:41 - Test SlopeSubset
let testMacros: [String: Macro.Type] = [ "SlopeSubset" : SlopeSubsetMacro.self, ] final class WWDCTests: XCTestCase { func testSlopeSubset() { assertMacroExpansion( """ @SlopeSubset enum EasySlope { case beginnersParadise case practiceRun } """, expandedSource: """ enum EasySlope { case beginnersParadise case practiceRun init?(_ slope: Slope) { switch slope { case .beginnersParadise: self = .beginnersParadise case .practiceRun: self = .practiceRun default: return nil } } } """, macros: testMacros ) } }
-
19:25 - Cast declaration to an enum declaration
guard let enumDecl = declaration.as(EnumDeclSyntax.self) else { // TODO: Emit an error here return [] }
-
21:14 - Extract enum members
let members = enumDecl.memberBlock.members
-
21:32 - Load enum cases
let caseDecls = members.compactMap { $0.decl.as(EnumCaseDeclSyntax.self) }
-
21:58 - Retrieve enum elements
let elements = caseDecls.flatMap { $0.elements }
-
24:11 - Generate initializer
let initializer = try InitializerDeclSyntax("init?(_ slope: Slope)") { try SwitchExprSyntax("switch slope") { for element in elements { SwitchCaseSyntax( """ case .\(element.identifier): self = .\(element.identifier) """ ) } SwitchCaseSyntax("default: return nil") } }
-
24:19 - Return generated initializer
return [DeclSyntax(initializer)]
-
25:51 - Apply SlopeSubset to EasySlope
/// Slopes suitable for beginners. Subset of `Slopes`. @SlopeSubset enum EasySlope { case beginnersParadise case practiceRun var slope: Slope { switch self { case .beginnersParadise: return .beginnersParadise case .practiceRun: return .practiceRun } } }
-
28:00 - Test that we generate an error when applying SlopeSubset to a struct
func testSlopeSubsetOnStruct() throws { assertMacroExpansion( """ @SlopeSubset struct Skier { } """, expandedSource: """ struct Skier { } """, diagnostics: [ DiagnosticSpec(message: "@SlopeSubset can only be applied to an enum", line: 1, column: 1) ], macros: testMacros ) }
-
28:48 - Define error to emit when SlopeSubset is applied to a non-enum type
enum SlopeSubsetError: CustomStringConvertible, Error { case onlyApplicableToEnum var description: String { switch self { case .onlyApplicableToEnum: return "@SlopeSubset can only be applied to an enum" } } }
-
29:09 - Throw error if SlopeSubset is applied to a non-enum type
throw SlopeSubsetError.onlyApplicableToEnum
-
31:03 - Generalize SlopeSubset declaration to EnumSubset
@attached(member, names: named(init)) public macro EnumSubset<Superset>() = #externalMacro(module: "WWDCMacros", type: "SlopeSubsetMacro")
-
31:33 - Retrieve the generic parameter of EnumSubset
guard let supersetType = attribute .attributeName.as(SimpleTypeIdentifierSyntax.self)? .genericArgumentClause? .arguments.first? .argumentType else { // TODO: Handle error return [] }
-
-
Looking for something specific? Enter a topic above and jump straight to the good stuff.
An error occurred when submitting your query. Please check your Internet connection and try again.