Swift 5.9 - Macros

Ezgi Üstünel
8 min readDec 6, 2023

--

Photo by Aaron Burden

We met Macros with Swift 5.9. Macros prevent repetitive codes and make the code more readable. Most of the time, crashes may occur in run time due to errors that we cannot see in compile time. Thanks to macros, we can see those errors at compile time and get rid of the boilerplate code to a large extent.

Macro types are divided into two: @freestanding and @attached.

1. @freestanding

This macro type replaces a piece of code. Sometimes they may return a value or create new declarations. They always start with the # sign. There are two types of freestanding macros: expression and declaration. Let’s start examining with examples…

a. @freestanding(expression)

Creates a piece of code that returns a value.

Let’s start with the “stringfy” macro that Apple offers developers for expression macros.

This macro takes one parameter. It returns the value and string description of the relevant parameter in a tuple. Let’s create a macro package and examine it in more detail.

First, create a macro under Xcode -> File -> New -> Package.

Let’s examine the package.swift file of the macro:

We can see the name of the package, which platforms it is supported on, and its products. There is “swift-syntax” package in the dependencies section. This library, which allows parsing written Swift codes and performing various operations, forms the basis of the macro. All the methods we will use when writing a macro belong to the swift-syntax package. In the targets section, .macro refers to the module where the macro body is written, .target refers to the module where the macro is defined, .executableTarget refers to the module where we can write client code for the macro and thus see the output, and finally, .testTarget refers to the module where we can write the tests for the macro.

When we create the macro, we see that stringfy comes as default. We can see the result when we run the relevant code in the main file under the ExampleBClient module. When we right-click on the macro method and select “Expand Macro”, we can see the expanded version of the macro method.

We mentioned that there are 3 basic modules. The macro is defined in the ExampleB module. The name of the macro, the parameters it will take, and the return value are determined. On the right side of the code, we write the type parameter in which module the macro body will be written (for this example, under the ExampleBMacros module) and the name of the struct in which we will write the macro body. (The struct name for the stringfy macro is determined as StringfyMacro.)

We mentioned that the body of the macro is written in the ExampleBMacro file under the ExampleBMacros module. Let’s examine StringfyMacro’s body.
The most important thing when writing a macro is to conform to the protocol of the relevant macro type because we will write the method body using the methods in these protocols. We conform to the “ExpressionMacro” protocol for expression macros and write the macro body in the “expansion” method of this protocol. First, let’s look at the parameters that the method takes:

node: It represents the parameters we write in parentheses when calling the macro. We will perform our operations with the node parameter because what is important to us are the parameters we give to the macro. We will obtain the desired return value by performing various operations with them.

context: provides more information about the compilation context in which the macro is being expanded.

and “ExprSyntax” is the return value. ExprSyntax is a type belonging to the swift-syntax library. It converts the returned value into swift expression.

In the method, we take the first element in the argument list of the node parameter. The reason for this is that we are expecting a single parameter in the stringfy macro. If we cannot access the first element, an error is returned stating that the macro does not have any parameters. If there is no error, the argument itself and the string description are returned as a tuple.

Now let’s write our little macro together.
Let’s code a URL macro that takes a string URL as a parameter. Our goal here will be to see invalid URL errors that we cannot see in compile time. To give an example, we write an invalid URL string in the code, but we cannot see the error at compile time and it crashes at run time. To avoid such situations, we use safe expressions such as guard let, or if let. Now, we will use guard let in the macro, but differently, see the error in compile time.

First, let’s define our macro called macroURL. This will take a string parameter.

We will use the expansion method again for the body part of the macro. Since we first want a single parameter in the method, as in the stringfy macro, we take the first expression from the argument list in the node. We expect this expression to be a string, so we cast this expression to the StringLiteralExprSyntax type by the swift syntax and then assign the segments of the expression to a variable.

So, let’s examine what segments mean here:

StringLiteralExprSyntax divides the string into segments. We can think of a tree structure as seen in the picture. The part between openQuote and closeQuote represents the segments, that is, the string we wrote. Since we expect a single string expression, namely segment, in the quote, we will perform operations with the 0th element of the segments array.

In the code snippet above, we assigned the segments in expression to a variable. As an extra check, we can also add the segments.counts == 1 check. Afterward, we can easily assign the first element in the segments array to a variable. If we encounter an error during these operations, we must throw an error in the else section. In this example, we threw the URLMacroError.requiresStaticStringLiteral error.
If we do not encounter any errors in all these processes, it is now time to convert the string variable we obtained into a URL. If we encounter an error while converting to the URL, we throw an error again. If everything is ok, we have now come to the return part of the method. Since we return in ExprSyntax format, we return the forced URL code in a quote.

Now let’s take a look at the client code…

Thanks to macroURL, we can now receive errors at compile time when a malformed URL is given as a parameter.

b. @freestanding(declaration)

Creates one or more declarations. Like struct, function, variable, or type.

In the example above, we can see that when we give a JSON model as a parameter to the jsonModel macro, it returns this jsonModel as a struct.

2. @attached

Attached macros are used as attributes on declarations in your code. They start with an @ sign. Attached macroların 5 tipi vardır: peer, accessor, memberAttribute, member, conformance. Let’s examine it.

a. @attached(peer)

Adds new declarations alongside the declaration it’s applied to.

In the example, we see that the @AddCompletionHandler macro is applied to the fetchAvatar method. This macro overloads the fetchAvatar and creates a separate method with the completion version. Thus, we can use both the async version and the completion version.

b. @attached(accessor)

Adds accessors to a property. Eg. adds get and set to a var. For example the @State in SwiftUI.

c. @attached(memberAttribute)

Adds attributes to the declarations in the type/extension it’s applied to.

The @AllPublished macro in the example adds the @published property wrapper to the beginning of all class variables.

d. @attached(member)

Member macros allow one to introduce new members to the type or extension to which the macro is attached. For example, we can write a macro that defines static members to ease the definition of an OptionSet. Given:

In the example, @OptionSetMembers macro is applied to the MyOptions struct. When we expand the macro, we see that it creates a rawValue variable and a static variable for each enum case.

e. @attached(conformance)

Conformance macros allow one to introduce new protocol conformances to a type. This would often be paired with other macros whose purpose is to help satisfy the protocol conformance. For example, one could imagine an extended version of the OptionSetMembers attributed shown earlier that also adds the OptionSet conformance.

In the previous example, the OptionSet protocol was conformed to the MyOptions struct. With the conformance macros, we can also perform conform operations within the macro.

This marks the end of this post so make sure to click on the clap button👏👏 if you liked it. See you in future posts…

--

--