Published Dec 28. 2023

Harnessing the Power of Swift Macros

| Swift 5.9 boats a whole lot of changes and improvements. One of the most important ones, namely Macros, could redefine how we write Swift code.

Introduction

Macros can be a great addition to new codebases that recently started.

Since Macros are only available with Swift 5.9 onwards, old projects won’t benefit as much. Nonetheless, Macros are an interesting topic few developers are familiar with yet.

What Are Macros?

Macros are a way to modify specific parts of our code. They enhance readability, decrease boilerplate code, and add functionality. Like Macros in real life — like a keyboard macro, which defines a series of simulated key presses when a combination is triggered — Macros apply a specific transformation to our code. This could be the addition of a property or function, conformance to another class, and much more. The process of changing code is called expanding.

Macros

| Macros expand a given source code: Apple Docs

When to Use Macros

Like with other features of languages, not all of them are useful for any situation. Macros shine when they can drastically reduce boilerplate code or streamline the readability of code. Nonetheless, a project can live without Macros just fine.

Especially when many developers in a project are unfamiliar with advanced concepts like Macros, there is an increased risk. On the other hand, developers can focus on writing code that matters once Macros are in place without worrying about Macro internals.

● ● ●

How Do Macros Work

Knowing what Macros are, let us focus on how Macros actually work. As mentioned before, Macros are used to expand source code. The compiler expands the code via the Macros rules during compile time. That is great because code is not generated and put into our projects but instead expanded under the hood during compilation.

Macros

| Source code vs expanded source code: Apple Docs

As illustrated in the image above, the Macro implementation is used to expand the source code during compilation. The implementation depends on the type of Macro and the needs it has to cover. Macros do work a little differently in certain situations. Or, to properly phrase it, there are different types of Macros for different scenarios.

Types of Macros

Swift features a whole lot of different Macro types. There are two categories; freestanding and attached Macros. The freestanding Macros are called like regular functions but with a # prefix. You might have encountered the Preview Macro already. It’s a great example of a freestanding Macro.

See Apple docs for the Preview Macro documentation.

Attached Macros are attached to a type, property or other declarations. They alter the attached scope of code. An example would be the newly introduced ObservableMacro. Again, see Apple docs for documentation of the Observable Macro.

Freestanding Macros

There are two types of freestanding Macros; expression and declaration. Let’s see when to use which:

  • @freestanding(expression)
    • Creates a piece of code that is an expression, e.g. 1+1
  • @freestanding(declaration)
    • Creates new declarations

Freestanding Macros are suitable for utility functions or simplified creation of new declarations.

Attached Macros

Attached Macros are attached to a declaration, as the name suggests. They alter the declaration by **extending **the provided code.

caution

Macros never change existing code; they only extend it.

There are many types of attached Macros:

  • @attached(peer)
    • Adds new declarations alongside the target declaration
  • @attached(accessor)
    • Adds accessors like get to a property
  • @attached(memberAttribute)
    • Adds attributes to declarations
  • @attached(member)
    • Adds new declarations to a type
  • @attached(extension)
    • Creates extensions of a type

The old conformance Macro is deprecated and should not be used. Instead, the extension macro is intended for use.

● ● ●

Using Macros

Now that we have a basic understanding of Macros, let’s see how we can introduce macros into our codebase.

Creating Macros

Before writing any Macro code, we have to create a Macro target. This is done by creating a new Swift Package with the Macro type.

Terminal: swift package init --type=macro

Xcode: Click on File -> New -> Package -> Swift Macro

Macro from Xcode

| Creating a Macro Package from Xcode

Macro Package Structure

Macro packages always require at least one **target **that implements the macros and one target that provides them.

Macro Package

| Implementation and Providing of Macros

The .macro target contains the Macro expansion code, while the .target provides the Macros to the external world. This allows the Macro provider to be fully decoupled from the Macro expansion code, allowing developers to quickly see what Macros are available without going through all the macro code.

Understanding Macros

To write a Macro, one has to create a public struct that conforms to a Macro type. Depending on the Macro type chosen, different expansion functions have to be provided.

note

There can be more than one conformance to Macro types.

Let’s have a look at the default Macro that is always created for new Macro packages. Open the previously created package and search for stringify. The whole code that provides the stringify Macro should look like this:

@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "testMacros", type: "StringifyMacro")

As we can see, the #externalMacro call tells the compiler to **look **for a Macro implementation in the module “testMacros” where the name matches “StringifyMacro”.

danger

It is important that the .macro target and the Macro name match both the annotations @freestanding(expression) and the name of the Macro. Otherwise the compiler will fail to expand the macro.

Let’s find the StringifyMacro code. It should look similar to this:

import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

/// Implementation of the `stringify` macro, which takes an expression
/// of any type and produces a tuple containing the value of that expression
/// and the source code that produced the value. For example
///
/// #stringify(x + y)
///
/// will expand to
///
/// (x + y, "x + y")
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))"
    }
}

@main
struct testPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        StringifyMacro.self
    ]
}

As you can see, the expansion function takes a node and a context, which we can query to get parameters and other things. Once we are happy with our Macro, we return an expression. It already stands out that we are returning some sort of syntax tree.

The framework that enables us to do so is called SwiftSyntax. It has been developed to parse, analyse and modify Swift code.

That means writing Macros requires some basic knowledge of syntax trees and the SwiftSyntax framework. See SwiftSyntax for documentation.

Writing Macros

Now that we know how to write basic Macros we can move on and create our first custom Macro.

The Macro we will be writing as an example will be a Macro that turns a type into a singleton.

Create a new file called SingletonMacro.swift. Paste the following code into it:

//
// SingletonMacro.swift
//
//
// Created by Marcel Kulina on 28.12.23.
//

import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct SingletonMacro: MemberMacro {
  public static func expansion(of node: SwiftSyntax.AttributeSyntax, providingMembersOf declaration: some SwiftSyntax.DeclGroupSyntax, in context: some SwiftSyntaxMacros.MacroExpansionContext) throws -> [SwiftSyntax.DeclSyntax] {
    guard let type = declaration.as(ClassDeclSyntax.self) else {
      fatalError("Compiler error: Type is not a class")
    }
    
    return [
      DeclSyntax(stringLiteral: "public static let shared = \(type.name.text)()"),
      DeclSyntax(stringLiteral: "private init() {}")
    ]
  }
}

Let’s break down what happens in the code:

  • First, we create a guard statement that ensures our Macro only works on classes.
  • Then we create a static let property and a private init and return both

tip

We can also create syntax tree nodes by building the objects one by one, instead of relying on string interpretation. For a reference of all the possible types, consult the SwiftSyntax documentation.

Providing Macros

Now that we have a finished Macro, we need to provide it. We already learned how to do so earlier.

Find macro stringify and paste the following code below:

@attached(member, names: named(shared), named(init))
public macro Singleton() = #externalMacro(module: "testMacros", type: "SingletonMacro")

caution

The added members have to be defined in the names argument. There is the option to allow any member, arbitrary, as well. Otherwise the compiler won't allow the members to be added.

We are not finished yet, though. We need to tell the Macro target what** Macros** are due to export. We do that by adding our Macro to the CompilerPlugin of this Macro target. Find the following code block similar to this in your Macro target:

@main
struct testPlugin: CompilerPlugin {
  let providingMacros: [Macro.Type] = [
    StringifyMacro.self,
  ]
}

Next, add SingletonMacro.self to the providingMacros array. Now the Macro is ready for use in projects.

To use it, just add @Singleton to your types.

● ● ●

Testing Macros

No feature is fool-safe. Swift provides a great new addition to XCTest for Macros.

Navigate to your Test target and search for the following code:

func testMacro()

Inside of the XCTestCase class, add a new func called testSingleton and paste the following code into it:

#if canImport(testMacros)
assertMacroExpansion(
"""
@Singleton class UserRepository {
}
""",
expandedSource:
"""
class UserRepository {
public static let shared = UserRepository()
private init() {
}
}
""",
macros: ["Singleton": SingletonMacro.self]
)
#else
throw XCTSkip("macros are only supported when running tests for the host platform")
#endif

Running this test ensures that the generated code always matches the expected code for the Macro. The assertMacroExpansion function essentially takes the input code, the expected result, and the macros that are available to the test.

Note that we are using compiler directives to check if the current context allows for import of the Macro target. This is needed because Macros are expanded on the host machine, thus running these tests on an iOS device would not work.

● ● ●

Where to Go From Here

We learned how to write basic Macros now. We also learned what are the benefits of Macros and how they change the way we write code.

One thing that no article can decide is when a Macro benefits a project. As a developer we always need to carefully decide when Macros are useful. They shouldn’t be looked at as just a gimmick. In the right situation, they can drastically reduce lines of code and increase readability.

As a note of caution though, Macros should not be the go-to solution for everything that could potentially be solved with Macros. Swift already has simpler features that can already solve some problems, like resultBuilders or generics. Only if none of the Swift features are appropriate, Macros should be chosen, due to their complexity.