Get Ready for Swift Macros

Leonardo Cardoso
Trade Republic Engineering
9 min readOct 23, 2023

--

Swift Macros were announced at this year’s WWDC. The moment I saw them, I instantly recognized a significant shift: there would be a time before Swift Macros and a time after Swift Macros. In fact, several newly announced powerful features in the new versions of Apple’s operating systems, such as @Observable, #Preview and @Model, are macros.

You might be asking, “What’s so special about them?”

tl;dr: They make code cleaner, reduce lines of code, and expedite feature implementation. All of that while keeping debugging and type safety in place.

Let’s get familiar with the innovative mechanics of Swift Macros and explore how to leverage their capabilities.

This is a two-part series. In the first article, we’ll introduce what Swift Macros are, exploring key topics like their roles and names, examining potential limitations, and concluding with insights into how the iOS Team at Trade Republic plans to utilize them. In the second article, we’ll dive into the technical implementation, showcasing how to write and test Swift Macros, along with a guide to best practices.

What It Is

Swift Macros are a groundbreaking way for generating boilerplate code and performing other compile-time operations. The phrase “compile-time” is crucial here because unlike other methods and without the need of running scripts, Swift Macros generate code during the project’s compilation, offering immediate availability.

Macros in Swift have an additive nature, meaning they can create new code, but they won’t alter or remove existing parts. This includes built-in safety checks, ensuring that the code entering and exiting the macro complies with Swift’s syntax, and that the types of values used align properly.

When you utilize a macro in your code, it often means concealing extra code that the macro symbolizes, but that just doesn’t need to be visible all the time. Conveniently, Xcode allows you to expand or collapse this generated code as desired.

If an error occurs during macro expansion, it’s treated as a compilation error by the compiler. These safeguards simplify the use of macros, aiding in correct use and timely identification of any issues.

How It Works

First, take note that a Macro is composed of two key elements: Definition and Implementation. These two separate parts must be located in different modules. The rationale behind this is that when your code triggers a macro, the Swift compiler identifies this call and directs it to a specialized compiler plug-in, where the logic of the macro resides.

The Swift compiler incorporates this expansion into your program, compiling both your original code and the newly-added expansion. When you run the application, it operates as though you had written the expansion manually.

Roles

Understanding the roles of Swift Macros is essential; it’s the key to selecting the most suitable one for your specific needs. In essence, these roles dictate where the code will be generated and how the compiler will treat the generated code. There are seven distinct roles, grouped into two categories as we will see below.

Before we go the the categories, we need to have in mind that when setting up a macro, several guidelines come into play: you’ll need to specify the category, identify the role, and — if applicable — configure role-specific settings. On top of this, attaching the macro keyword is a must, and remember that macros can accept arguments and, some, return types.

For instance, here’s what a macro definition looks like:

Freestanding Macros

The first category is named freestanding macros and their symbol is the pound sign ‘#’. These macros are self-sufficient, with their generated code positioned exactly where it was defined. In this category, there are two roles: #expression and #declaration macros.

#expression

Expression macros have the capability to transform and expand source code by executing flexible syntax adjustments on their parameters to achieve the intended output.

To illustrate, consider the scenario where the goal is to replace all instances of force-unwrapped URLs with a well-tested URL macro, designed for situations where the URL’s existence is guaranteed. This can be done by an expression macro.

This comes in particularly handy when you have a lint tool in place that frowns upon force-unwrapping. In fact, we’ve successfully managed to do away with the need for force-unwrapping altogether.

#declaration

This macro operates similarly to the Expression macro, but it diverges in that it can’t return a type. A concrete example of a declaration macro is one that regenerates a JSON string into a corresponding Swift model.

Attached Macros

The second category is known as attached macros and is identified by the ‘@’ symbol. Unlike their freestanding counterparts, attached macros need an associated entity for code generation. Within this category, you’ll find five distinct roles: @member, @memberAttribute, @extension, @peer, and @accessor. These macros are versatile and can be applied to a wide range of entities, including methods, structs, classes, extensions.

@member

Member macros serve to amplify the entity to which they’re applied by creating code, new declarations, within that very entity. To give you a concrete example, let’s say you want default initializers for each property in a struct. A Member macro would be your go-to solution for this task.

Macros give us comprehensive insights into the entity, from the accessor control right down to the type. This allows us to tailor the output as needed.

@memberAttribute

Member Attribute macros are designed to attach attributes to declarations in the type or extension where they are implemented. This functionality enables you to modify the properties or methods in your code through metadata or specific compiler directives.

@extension

Extension Macros add code at the same level as the original entity. Imagine being able to append necessary functionalities to a class or struct without cluttering its primary definition. This type offers this level of cleanliness, keeping your main entity definitions concise while still affording you the flexibility to add as much functionality as needed. Check the example below:

@peer

The Peer Macro closely mirrors the Extension macro in appearance, placing the generated code at the same level as the original entity and offering the same degree of flexibility in terms of adding new code. However, there’s a key difference: the Peer Macro actually creates a new entity. This makes it an ideal choice for generating mock structures, for instance.

@accessor

An Accessor macro serves the purpose of generating accessors for a given property, thereby converting what was initially a stored property into a computed one.

Arguments

Some macros offer configurable settings to fine-tune the code generation. These settings typically fall into two categories: Names and Conformances.

Names

Macros can add declarations based on the names specified in this argument. Since these names are predetermined, they become instantly accessible in the generated code. This is applicable to various elements like properties, methods, option sets, and enumeration cases. The names argument is not limited to one, allowing you to specify multiple names for greater flexibility.

Further customization is available through specifiers such as overloaded, named(*), prefixed(*), suffixed(*), and arbitrary.

  • overloaded: For declarations that use the specified name as their base name.
  • named: For declarations with a specific, fixed name.
  • prefixed and suffixed: For declarations that include a particular prefix or suffix.
  • arbitrary: For declarations that don’t fit into the other categories.

Conformance

In the context of Extension macros, the conformances argument is essential when that extension is conforming to a protocol because it ensures that it is recognized by the compiler as such.

What It Can and Cannot See

Scope

It’s crucial to understand that macros are limited to the scope of the entity to which they are applied; they can’t access anything outside of that scope. For example, if you apply a macro to a protocol that inherits from another protocol, the parent protocol remains inaccessible.

Availability

The code generated by one macro isn’t accessible to another macro during the compilation stage. For instance, if one macro generates initializers, those can’t serve as parameter values for another macro.

Constraints

Some macros may demand explicit code, even when your own macro can generate that very code. To illustrate, let’s revisit the example of a macro that creates initializers. If you apply a new @Model macro, it will still require the implementation of an initializer.

Considerations

Type Safety

Swift Macros upholds the language’s commitment to type safety. Even though macros interpret everything ultimately as strings, they allow for the input of parameters using their original types. It’s important, however, to be mindful of where you place your macro definitions. At the same time, they need visibility of the relevant structures and prevent cyclic dependencies. Here’s a pro tip: a macro can be internal to any package, but its implementation must reside in a separate module as explained before, so you can use it as leverage to only expose modules to other packages when needed.

Difference from Property Wrappers

You might be inclined to think that Macros and property wrappers serve the same purpose, especially because they look quite similar at a glance. However, they operate at different phases of the development cycle.

Property wrappers come into play at runtime, dynamically interacting with your wrapped values by applying any added logic you’ve programmed. In contrast, Macros operate strictly at compile-time, enriching your codebase before your application even starts running.

Caching

After a Macro’s code is compiled and the new elements are added, that code stays put until you perform a full build again. If you decide to change your Macro code, it won’t update automatically because it’s cached. This caching mechanism helps to eliminate redundant builds, making the development process more efficient.

Boilerplate

In the topic of Boilerplate, there are two ways for writing macros, which we’ll explore in more detail in the second part of this article. A small spoiler: you can either craft macros as extensive strings or use a more type-safe approach with the objects provided by Swift Syntax. The latter is generally preferred, but it can be a bit unwieldy due to the potential size of the code blocks. However, it offers the advantage of minimizing boilerplate code in the long run. So, if you’re writing a macro that may not eliminate much repetitive code now, it could pay off in the future.

Conclusion (and How We Plan to Utilize Swift Macros)

Our codebase is robust, maintained by a talented team of nearly 30 iOS engineers. One of our first applications for Swift Macros is to replace our current mock generator, which is responsible for creating close to 1,000 files and approximately 120,000 lines of code.

Going forward, we aim to broaden the use of Macros in our testing stack. Specifically, we plan to replace manually created Entity Builders that help us in writing tests, among other enhancements.

In conclusion, the adoption of Swift Macros promises to be a game-changer for our development processes. Not only will it streamline the way we generate code, but it will also enhance the modularity and reusability of our codebase, allowing the engineers to focus on building more complex and creative solutions.

Further Content

--

--