This is the second part of my previous blog post about Swift Macros. In this one, you’ll learn how to create your own macro from scratch, simple and straightforward.
Before we dive in, let’s quickly go over why you’d even want to create a macro in the first place. We’ve been working on a macro library for mocks, which we’re currently using and refining internally. By the way, it’ll be open-sourced soonish, so stay tuned!
We decided to tackle mocks because our previous setup, using the amazing Sourcery, while functional, relied heavily on code generation using external tools like Stencil and YAML. On top of that, we had to manage the generated files through separate testing packages. This was necessary because, when using those mocks as dependencies, we only needed to include the testing package itself.
The macro-based approach we built replicates what our Sourcery integration used to do, but in a way that speeds up adoption internally and minimizes the amount of required changes. It helped us cut down over 18,000+ lines of code in the first few commits alone, just by migrating from the old codegen setup. Since it’s written entirely in Swift, engineers no longer have to fiddle with extra config files or manually update mocks. The setup is much cleaner now, which means better productivity and fewer headaches. Plus, with fewer module dependencies, the overall architecture is much simpler too and the compilation times have improved as well.
Understanding the Best Role For You
First of all, you need to figure out what kind of macro you want to create. There are 7 macro roles in Swift, divided into 2 main categories: freestanding and attached. They’re pretty straightforward. Here’s a quick overview:
Freestanding Macros
These are used in places where you just call the macro directly, kind of like functions or attributes. They can exist by themselves.
Expression Macros (#expression): These macros generate expressions. For example, replacing force-unwrapped URLs with a safer, compile-time-checked version.
Declaration Macros (#declaration): These macros generate new declarations, such as creating a Swift model from a JSON string.
Attached Macros
These are macros that attach to existing declarations (like types, functions, or variables) using the @ symbol.
Member Macros (@member): Add new declarations within the entity they’re attached to, like generating public initializers for a struct.
Member Attribute Macros (@memberAttribute): Attach attributes to declarations, modifying properties or methods with metadata or compiler directives.
Extension Macros (@extension): Add code at the same level as the original entity, allowing you to append functionalities without cluttering the primary definition.
Peer Macros (@peer): Similar to extension macros but create new entities at the same level, useful for generating mock structures.
Accessor Macros (@accessor): Generate accessors for properties, converting stored properties into computed ones.
Writing the Macro
Learning how to write a macro comes with a bit of a learning curve, but once you get the hang of it, it becomes way simpler. You might even start thinking in macros!
So, first things first: you’ll need to create a package for your macro. Fortunately, Xcode provides a built-in template you can use to bootstrap your project quickly.
The template already includes a very simple freestanding expression macro. It returns both a value and a string representing the original source code.
A macro in Swift needs two packages: one for the declaration and one for the implementation. In the default template, the declaration lives in the MyFirstMacro
folder, and the implementation is in MyFirstMacroMacros
.
If we take a look at the Package.swift
, it’s clearly defined:
targets: [
.macro(
name: "MyFirstMacroMacros",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
]
),
.target(name: "MyFirstMacro", dependencies: ["MyFirstMacroMacros"]),
.executableTarget(name: "MyFirstMacroClient", dependencies: ["MyFirstMacro"]),
.testTarget(
name: "MyFirstMacroTests",
dependencies: [
"MyFirstMacroMacros",
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
]
),
]
Having this default setup is great to get started quickly, but that’s not what we’re aiming for here. Instead, we’re going to implement our own macro that automatically generates a public initializer for public entities.
Bootstrapping our Macro
Let’s start by creating a barebones macro. To do that, we’ll need to create two files:
Macro Declaration – This lives in the library package. Let’s name it
InitifyMacro.swift
.Macro Implementation – This goes in the macro implementation target. We’ll call this file
InitifyMacroImplementation.swift
.
Now that we’ve got two empty files, it’s time to add some actual code.
Since we want to generate an initializer inside the scope of the struct or class, our InitifyMacro
will be an attached member macro.
@attached(member)
public macro Initify() = #externalMacro(
module: "MyFirstMacroMacros",
type: "InitifyMacro"
)
Once the declaration is defined, we can move on to the implementation, starting with just the declaration itself. In InitifyMacroImplementation.swift
, let’s add the following:
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
public struct InitifyMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
[]
}
}
Lastly, we need to update, or create, the Compiler Plugin export. This step is required to register your code as a macro so Swift knows about it.
import SwiftCompilerPlugin
import SwiftSyntaxMacros
@main
struct MyFirstMacroPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
InitifyMacro.self,
]
}
At this point, if you apply the @Initify macro to any entity, nothing will happen. So let’s move on and write the internals of the macro to actually generate some code.
TDD for the Win
There’s a lot of back and forth in macro development, so using TDD is hands down the best way to write and update your macros. Seriously, it’ll save you time, reduce bugs, and make your macro logic way easier to maintain as it grows.
And, interestingly enough, testing in Swift Macros relies on comparing strings. That’s right! But it’s a bit more involved than it sounds.
We use a special assertion called #assertMacroExpansion
, which compares the original source code to the expected result after the macro is expanded. It also provides helpful hints when something in the expansion fails, making debugging way easier.
So, let’s create a test file to try it out with a simple example:
#if canImport(MyFirstMacroMacros)
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
import XCTest
import MyFirstMacroMacros
let testMacros: [String: Macro.Type] = [
"Initify": InitifyMacro.self,
]
final class MyFirstMacroTests: XCTestCase {
func testMacro() throws {
assertMacroExpansion(
"""
@Initify
public struct Card {
let rank: Rank
let suit: String
let value: Int
}
""",
expandedSource: """
public struct Card {
let rank: Rank
let suit: String
let value: Int
public init(rank: Rank, suit: String, value: Int) {
self.rank = rank
self.suit = suit
self.value = value
}
}
""",
macros: testMacros
)
}
}
#endif
If we run this test right now… it’ll fail! And that’s totally expected. Our macro is still not generating anything yet. But the beauty of it is that the failing test, as seen below, tells us exactly what’s missing between the lines 6-10. That’s one step closer to fully understanding how our macro works. Enter: SwiftSyntax.
failed - Macro expansion did not produce the expected expanded source
public struct Card {
let rank: Rank
let suit: String
let value: Int
– public init(rank: Rank, suit: String, value: Int) {
– self.rank = rank
– self.suit = suit
– self.value = value
– }
}
SwiftSyntax and The Tree Representation
SwiftSyntax (REF) is the library that powers macro development. It’s what generates the syntax tree representation of Swift code. It’s basically a structured map of everything: declarations, variables, parameters, functions, and more. With it, we can inspect this tree like an X-Ray, that will help us figure out exactly what we need to write to make our macro do its job.
If we head over to the InitifyMacro
in InitifyMacroImplementation.swift
and dump the declaration, here’s what we get:
...
) throws -> [DeclSyntax] {
dump(declaration)
return []
}
...
- StructDeclSyntax
├─attributes: AttributeListSyntax
│ ╰─[0]: AttributeSyntax
│ ├─atSign: atSign
│ ╰─attributeName: IdentifierTypeSyntax
│ ╰─name: identifier("Initify")
├─modifiers: DeclModifierListSyntax
│ ╰─[0]: DeclModifierSyntax
│ ╰─name: keyword(SwiftSyntax.Keyword.public)
├─structKeyword: keyword(SwiftSyntax.Keyword.struct)
├─name: identifier("Card")
╰─memberBlock: MemberBlockSyntax
├─leftBrace: leftBrace
├─members: MemberBlockItemListSyntax
│ ├─[0]: MemberBlockItemSyntax
│ │ ╰─decl: VariableDeclSyntax
│ │ ├─attributes: AttributeListSyntax
│ │ ├─modifiers: DeclModifierListSyntax
│ │ ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.let)
│ │ ╰─bindings: PatternBindingListSyntax
│ │ ╰─[0]: PatternBindingSyntax
│ │ ├─pattern: IdentifierPatternSyntax
│ │ │ ╰─identifier: identifier("rank")
│ │ ╰─typeAnnotation: TypeAnnotationSyntax
│ │ ├─colon: colon
│ │ ╰─type: IdentifierTypeSyntax
│ │ ╰─name: identifier("Rank")
│ ├─[1]: MemberBlockItemSyntax
│ │ ╰─decl: VariableDeclSyntax
│ │ ├─attributes: AttributeListSyntax
│ │ ├─modifiers: DeclModifierListSyntax
│ │ ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.let)
│ │ ╰─bindings: PatternBindingListSyntax
│ │ ╰─[0]: PatternBindingSyntax
│ │ ├─pattern: IdentifierPatternSyntax
│ │ │ ╰─identifier: identifier("suit")
│ │ ╰─typeAnnotation: TypeAnnotationSyntax
│ │ ├─colon: colon
│ │ ╰─type: IdentifierTypeSyntax
│ │ ╰─name: identifier("String")
│ ╰─[2]: MemberBlockItemSyntax
│ ╰─decl: VariableDeclSyntax
│ ├─attributes: AttributeListSyntax
│ ├─modifiers: DeclModifierListSyntax
│ ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.let)
│ ╰─bindings: PatternBindingListSyntax
│ ╰─[0]: PatternBindingSyntax
│ ├─pattern: IdentifierPatternSyntax
│ │ ╰─identifier: identifier("value")
│ ╰─typeAnnotation: TypeAnnotationSyntax
│ ├─colon: colon
│ ╰─type: IdentifierTypeSyntax
│ ╰─name: identifier("Int")
╰─rightBrace: rightBrace
That’s the full structure of our Card
entity, and the types ending with Syntax
are classes inside the SwiftSyntax
library. Since we want to generate an initializer for its properties and I can see the properties are inside something called memberBlock
, I know that’s the right spot I need to go to extract the data and directives for building the initializer.
And here comes the magic…
I know where the properties are, but I don’t yet know which SwiftSyntax types or builders to use to generate them.
The trick is just go to your test, add the part of the code you expect to be generated directly in the original source code, and run the test again. The dump output reveals what you need to build.
It’s like the compiler is giving you a to-do list. Just follow the breadcrumbs.
assertMacroExpansion(
"""
@Initify
public struct Card {
let rank: Rank
let suit: String
let value: Int
public init(rank: Rank, suit: String, value: Int) {
self.rank = rank
self.suit = suit
self.value = value
}
}
""",
...
)
We notice that there’s a new item of the type InitializerDeclSyntax
in the output.
│ ╰─[3]: MemberBlockItemSyntax
│ ╰─decl: InitializerDeclSyntax
│ ├─attributes: AttributeListSyntax
│ ├─modifiers: DeclModifierListSyntax
│ │ ╰─[0]: DeclModifierSyntax
│ │ ╰─name: keyword(SwiftSyntax.Keyword.public)
│ ├─initKeyword: keyword(SwiftSyntax.Keyword.init)
│ ├─signature: FunctionSignatureSyntax
│ │ ╰─parameterClause: FunctionParameterClauseSyntax
│ │ ├─leftParen: leftParen
│ │ ├─parameters: FunctionParameterListSyntax
│ │ │ ├─[0]: FunctionParameterSyntax
│ │ │ │ ├─attributes: AttributeListSyntax
│ │ │ │ ├─modifiers: DeclModifierListSyntax
│ │ │ │ ├─firstName: identifier("rank")
│ │ │ │ ├─colon: colon
│ │ │ │ ├─type: IdentifierTypeSyntax
│ │ │ │ │ ╰─name: identifier("Rank")
│ │ │ │ ╰─trailingComma: comma
│ │ │ ├─[1]: FunctionParameterSyntax
│ │ │ │ ├─attributes: AttributeListSyntax
│ │ │ │ ├─modifiers: DeclModifierListSyntax
│ │ │ │ ├─firstName: identifier("suit")
│ │ │ │ ├─colon: colon
│ │ │ │ ├─type: IdentifierTypeSyntax
│ │ │ │ │ ╰─name: identifier("String")
│ │ │ │ ╰─trailingComma: comma
│ │ │ ╰─[2]: FunctionParameterSyntax
│ │ │ ├─attributes: AttributeListSyntax
│ │ │ ├─modifiers: DeclModifierListSyntax
│ │ │ ├─firstName: identifier("value")
│ │ │ ├─colon: colon
│ │ │ ╰─type: IdentifierTypeSyntax
│ │ │ ╰─name: identifier("Int")
│ │ ╰─rightParen: rightParen
│ ╰─body: CodeBlockSyntax
│ ├─leftBrace: leftBrace
│ ├─statements: CodeBlockItemListSyntax
│ │ ├─[0]: CodeBlockItemSyntax
│ │ │ ╰─item: SequenceExprSyntax
│ │ │ ╰─elements: ExprListSyntax
│ │ │ ├─[0]: MemberAccessExprSyntax
│ │ │ │ ├─base: DeclReferenceExprSyntax
│ │ │ │ │ ╰─baseName: keyword(SwiftSyntax.Keyword.self)
│ │ │ │ ├─period: period
│ │ │ │ ╰─declName: DeclReferenceExprSyntax
│ │ │ │ ╰─baseName: identifier("rank")
│ │ │ ├─[1]: AssignmentExprSyntax
│ │ │ │ ╰─equal: equal
│ │ │ ╰─[2]: DeclReferenceExprSyntax
│ │ │ ╰─baseName: identifier("rank")
│ │ ├─[1]: CodeBlockItemSyntax
│ │ │ ╰─item: SequenceExprSyntax
│ │ │ ╰─elements: ExprListSyntax
│ │ │ ├─[0]: MemberAccessExprSyntax
│ │ │ │ ├─base: DeclReferenceExprSyntax
│ │ │ │ │ ╰─baseName: keyword(SwiftSyntax.Keyword.self)
│ │ │ │ ├─period: period
│ │ │ │ ╰─declName: DeclReferenceExprSyntax
│ │ │ │ ╰─baseName: identifier("suit")
│ │ │ ├─[1]: AssignmentExprSyntax
│ │ │ │ ╰─equal: equal
│ │ │ ╰─[2]: DeclReferenceExprSyntax
│ │ │ ╰─baseName: identifier("suit")
│ │ ╰─[2]: CodeBlockItemSyntax
│ │ ╰─item: SequenceExprSyntax
│ │ ╰─elements: ExprListSyntax
│ │ ├─[0]: MemberAccessExprSyntax
│ │ │ ├─base: DeclReferenceExprSyntax
│ │ │ │ ╰─baseName: keyword(SwiftSyntax.Keyword.self)
│ │ │ ├─period: period
│ │ │ ╰─declName: DeclReferenceExprSyntax
│ │ │ ╰─baseName: identifier("value")
│ │ ├─[1]: AssignmentExprSyntax
│ │ │ ╰─equal: equal
│ │ ╰─[2]: DeclReferenceExprSyntax
│ │ ╰─baseName: identifier("value")
│ ╰─rightBrace: rightBrace
╰─rightBrace: rightBrace
Now we’re all set.
We know what we need to build, we know where to look to generate the initializer, and we’re ready to write the actual code.
Now let’s just go back to our macro declaration and add the names
parameter to the annotation. The value named(init)
will tell the compiler that this macro needs to be evaluated at the initializer phase.
@attached(member, names: named(init))
public macro Initify() = #externalMacro(
module: "MyFirstMacroMacros",
type: "InitifyMacro"
)
Let’s put it all together and bring our @Initify
macro to life!
Cooking the @Initify
Macro.
First, we need to get the list of members from the entity.
In our case, we know the entity is a struct, so we can cast the declaration as a StructDeclSyntax
. Once we do that, we can access its memberBlock
to get the members. If you dump the members, you’ll see a slice of the original syntax tree.
) throws -> [DeclSyntax] {
guard let structDecl = declaration.as(StructDeclSyntax.self) else { return [] }
let members = structDecl.memberBlock.members
dump(members)
return []
}
Now we need to create a new instance of the InitializerDeclSyntax
and pass the new members and create its body.
Let’s convert the members into parameters by analyzing the tree representations. This is the code:
let memberAsFunctionParameters: [FunctionParameterSyntax] = members.compactMap { member -> FunctionParameterSyntax? in
guard
let decl = member.decl.as(VariableDeclSyntax.self),
let binding = decl.bindings.first,
let name = binding.pattern.as(IdentifierPatternSyntax.self),
let type = binding.typeAnnotation?.type
else { return nil }
// rank: Rank
return FunctionParameterSyntax(
firstName: name.identifier,
type: type
)
}
Now we also need to convert them into assignments to go in the initializer body:
let memberAsCodeBlockItems: [SequenceExprSyntax] = members.compactMap { member -> SequenceExprSyntax? in
guard
let decl = member.decl.as(VariableDeclSyntax.self),
let binding = decl.bindings.first,
let name = binding.pattern.as(IdentifierPatternSyntax.self)
else { return nil }
return SequenceExprSyntax(
elements: ExprListSyntax {
// self.rank
MemberAccessExprSyntax(
base: DeclReferenceExprSyntax(baseName: TokenSyntax.keyword(.self)),
declName: DeclReferenceExprSyntax(baseName: name.identifier.trimmed)
)
// =
AssignmentExprSyntax()
// rank
DeclReferenceExprSyntax(baseName: name.identifier.trimmed)
}
)
}
And let’s now apply them to the initializer:
let initializerDeclSyntax = InitializerDeclSyntax(
modifiers: DeclModifierListSyntax {
DeclModifierSyntax(name: TokenSyntax.keyword(.public))
},
signature: FunctionSignatureSyntax(
parameterClause: FunctionParameterClauseSyntax {
memberAsFunctionParameters
}
),
body: CodeBlockSyntax(
statements: CodeBlockItemListSyntax {
memberAsCodeBlockItems
}
)
)
return [DeclSyntax(initializerDeclSyntax)]
Our macro is finally done! If you run the tests now… they’ll pass!
Wrapping Up
And that’s it! You’ve learned how to create your own macro, understand its types, and explore the context using the syntax tree. Powerful stuff, but we’re just getting started.
Remember, this is only your first macro. The syntax tree gives you access to everything: variables, functions, parameters, extensions, you name it. It’s all there. And you can also pass parameters in the macro itself, e.g. for default values. You just have to dive in and explore.
Swift Macros can seriously level up your tooling. Once you get the hang of them, they’ll start to feel like a natural extension of how you write code.
Got questions or feedback? Drop a comment or reach out, I’d love to hear what you’re building with Swift Macros!