Swift: long live macro!

Aldrian Kwan
8 min readJan 11, 2024

Starting from Swift 5.9, developers can leverage the power of macro in their apps to make life easier! While users of other languages such as C/C++, Rust, or even Obj-C have had the privilege of using macros for a long time, Swift finally released an official support in 2023.

A macro “expands” some bits of code into another one in accordance to its implementation. Unlike a conventional code generation method (i.e. generate statically validated code into a new file), the generated code by a macro is parsed and validated directly by the compiler before build time. This means any error, warning, or diagnostic messages will be shown directly in xcode without us having to compile the whole app first.

Swift macro depends heavily on https://github.com/apple/swift-syntax, because it works on top the Abstract Syntax Tree (AST) of your code. Swift macro depends on a swift-syntax version which is tied to a specific swift version (509 is 5.9, etc.).

There is an awesome tool available to help navigating swift AST at https://github.com/SwiftFiddle/swift-ast-explorer. You can use the tool to see how you should structure your macro implementation, including how an input is parsed before being passed as argument to a macro.

Now, let’s take a look at how Swift macros work!

Freestanding macro

Freestanding macro is denoted by the pound symbol (#) at the beginning, followed its name, then parentheses if any parameters are declared. Just like what the name implies, freestanding macro are not “attached” to anything. You can (mostly) use them wherever an expression fits.

func foo(line: Int = #line) { // a macro
#warning("message") // another macro
}

Users of swift are mostly familiar with built-in compiler directives such as #warning ,#error and special literals #column , #file , #function , and #line . As of Swift 5.9, these are now implemented as freestanding macros.

Expression macro

Freestanding expression macro transform a piece of code into expressions. Expression macro behave similarly to a function where it may have parameter and return type which tell us the “expansion” behavior.

// in MyMacro target
@freestanding(expression)
public macro stringify<T>(_ input: T) -> String =
#externalMacro(module: "MyMacroImplemention", type: "StringifyMacro")

// in MyMacroImplemention target
public struct StringifyMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> ExprSyntax {
guard let argument = node.argumentList.first?.expression else {
fatalError("compiler bug: the macro does not have any arguments")
}

return "\(literal: argument.description)"
}
}

// in A client project with MyMacro as dependency

let _ : String = #stringify(1 + 1) // "1 + 1"

In the example above, the macro “expands” whatever the input is into its string representation. the argument variable is actually anExprSyntax instance which is the AST representation of the input “1 + 1”(node.argumentList.first?.expression).

for further reading of expression macro, see swift proposal 0382-expression-macros

Declaration Macro

Declaration macro proposal extends freestanding expression macro to introduce new declarations. They are defined similarly except that they do not have return type. A declaration macro can be used anywhere a declaration fits.

// in MyMacro target

@freestanding(declaration, names: named(MyClass)) // introduce a new type, "MyClass"
public macro FuncUnique() =
#externalMacro(module: "MyMacroImplemention", type: "FuncUniqueMacro")

// in MyMacroImplemention target
public enum FuncUniqueMacro: DeclarationMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
let name = context.makeUniqueName("unique")
return [
"""
class MyClass {
func \(name)() {}
}
"""
]
}
}

// in A client project with MyMacro as dependency

#FuncUnique
/// This will declare the following
/// class MyClass {
/// func [someRandomNameHere]() {}
/// }
print(MyClass())

In the example above, the macro adds a new declaration MyClass with a method named randomly containing the string “unique”. The print statement is compiled successfully since MyClass is generated by the compiler.

A declaration macro may have names parameter which contains the new declarations it will provide via named(…). If the declarations are unknown beforehand, pass arbitrary instead.

for further reading of declaration macro, see proposal 0389-attached-macros

Attached Macro

Attached macro is denoted by at sign (@) at the beginning, similar to how a property wrapper is used. Similar to freestanding macro, they may have parameters. Unlike freestanding macro, attached macro is called as such because they are “attached”/must be directly related to a declaration (i.e. struct , class, enum, func, or var/let )

@SomeMacro<SomeType>
class Foo {
@AnotherMacro
func foo() {}
}

Peer macro

Peer macro adds new declarations next to the declaration they are attached to. So for example, a macro @AddBar will add new func bar next to the declaration it is attached to:

@AddBar
func foo() {}

/// The complete, expanded code will be:
/// func foo() {}
/// func bar() {}

A peer macro may be declared and implemented as such

// in MyMacro target
@attached(peer, names: suffixed(Peer)) // "suffixed" type
public macro PeerValueWithSuffixName() =
#externalMacro(module: "MyMacroImplementation", type: "PeerValueWithSuffixNameMacro")

// in MyMacroImplementation target
public enum PeerValueWithSuffixNameMacro: PeerMacro {
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let identified = declaration.asProtocol(NamedDeclSyntax.self) else {
return []
}
return ["""
class \(raw: identified.name.text)_peer {
func foo() {
print(self)
}
}
"""]
}
}

// in a client code
@PeerValueWithSuffixName
class Counter {
var value = 0
}
/// The complete, expanded code will be:
///
/// class Counter {
/// var value = 0
/// }
/// class CounterPeer {
/// func foo() {
/// print(self)
/// }
/// }

The @PeerValueWithSuffixName macro generate a new class, which takes the name of the declaration it is attached to (class Counter) and “Peer”, resulting in CounterPeer .

as you can see, the maco declaration takes a parameter names: suffixed(Peer) , meaning the declarations it creates will always be suffixed with “Peer”. If any of its generated declarations does not match the argument, then the compiler returns an error.

Member macros

Member macros introduces new member to the declaration it is attached to. In this case, member macro can only be used on type or extension declaration.

// in MyMacro target
/// Add computed properties named `is<Case>` for each case element in the enum.
@attached(member, names: arbitrary)
public macro CaseDetection() =
#externalMacro(module: "MyMacroImplementation", type: "CaseDetectionMacro")

// in MyMacroImplementation target
public enum CaseDetectionMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
declaration.memberBlock.members
.compactMap { $0.decl.as(EnumCaseDeclSyntax.self) }
.map { $0.elements.first!.name }
.map { ($0, $0.initialUppercased) }
.map { original, uppercased in
"""
var is\(raw: uppercased): Bool {
if case .\(raw: original) = self {
return true
}

return false
}
"""
}
}
}

extension TokenSyntax {
fileprivate var initialUppercased: String {
let name = self.text
guard let initial = name.first else {
return name
}

return "\(initial.uppercased())\(name.dropFirst())"
}
}

// in client code
@CaseDetection
enum Pet {
case dog
case cat(Bool)
/// these are generated
/// var isCat: Bool {
/// if case .cat = self { return true }
/// return false
/// }

/// var isDat: Bool {
/// if case .dog = self { return true }
/// return false
/// }
}

let pet: Pet = .cat(true)
print("Pet is dog: \(pet.isDog)") // false
print("Pet is cat: \(pet.isCat)") // true

the example above looks for all cases in an enum and generate new computed property for each case where it returns true if the self instance matches the enum case. The computed property always take the name of the case, capitalize it, and prefix it with is . So for example, Pet.cat.isCat returns true

Member attribute macro

While member macro adds new member to a type/extension, this macro modifies explicitly declared member of the type.

// in MyMacro target
@attached(memberAttribute)
public macro memberDeprecated() =
#externalMacro(module: "MyMacroImplementation", type: "MemberDeprecatedMacro")

// in MyMacroImplementation target
public enum MemberDeprecatedMacro: MemberAttributeMacro {
public static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingAttributesFor member: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [AttributeSyntax] {
return ["@available(*, deprecated)"]
}
}

// in client code
@memberDeprecated
struct SomeStruct {
func foo() {}
}

SomeStruct.foo() // will produce a deprecation warning

Member attribute can be used to introduce other macro or attributes, and thus is mainly used to compose multiple macro/attributes.

Accessor Macro

Accessor macro can only be attached to a property. When attached to a stored property, it can turn it into a computed property


// in MyMacro target
@attached(accessor)
public macro DictionaryStorage() =
#externalMacro(module: "MyMacroImplementation", type: "DictionaryStorageMacro")

// in MyMacroImplementation target
public struct DictionaryStorageMacro: AccessorMacro {
public static func expansion<
Context: MacroExpansionContext,
Declaration: DeclSyntaxProtocol
>(
of node: AttributeSyntax,
providingAccessorsOf declaration: Declaration,
in context: Context
) throws -> [AccessorDeclSyntax] {
guard let varDecl = declaration.as(VariableDeclSyntax.self),
let binding = varDecl.bindings.first,
let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier,
binding.accessorBlock == nil,
let type = binding.typeAnnotation?.type
else {
return []
}

guard let defaultValue = binding.initializer?.value else {
throw CustomError.message("stored property must have an initializer")
}

return [
"""
get {
_storage[\(literal: identifier.text), default: \(defaultValue)] as! \(type)
}
""",
"""
set {
_storage[\(literal: identifier.text)] = newValue
}
""",
]
}
}

// in client code

struct Foo {
private var _storage: [String: Any] = [:]
@DictionaryStorage
var x: Int = 1
/// becomes
/// var x: Int {
/// get {
/// _storage["x", default: 1] as! Int
/// }
/// set {
/// _storage["x"] = newValue
/// }
/// }
}

In this example, the macro assumes the property it is attached to has access to another property, _storage . In this case, we modify the stored property, x into a computed property that retrieve and stores its value from a dictionary _storage .

Conformance & Extension macro

A conformance macro conforms a type to another and by itself was fairly straightforward. But as of this proposal, the conformance macro has been replaced by extension macro

// in MyMacro target
@attached(extension, conformances: Equatable)
public macro equatable() =
#externalMacro(module: "MyMacroImplementation", type: "EquatableExtensionMacro")

// in MyMacroImplementation
public enum EquatableExtensionMacro: ExtensionMacro {
public static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingExtensionsOf type: some TypeSyntaxProtocol,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [ExtensionDeclSyntax] {
let equatableExtension = try ExtensionDeclSyntax("extension \(type.trimmed): Equatable {}")

return [equatableExtension]
}
}

// in client
@equatable
struct Pet {
let name: String
}
// generates an extension below, somewhat similar to Peer macro
// extension Pet: Equatable {}

Pet("dog") == Pet("cat") // false

extension macro can also introduce new declarations inside the extension just like you would with a typical extension.

Last few notes…

macro works similarly to package plugin, where it is compiled as executables in the host platform (where the compiler is runing from). Macros will always be made available transitively, meaning if a library depends on a macro target, then any client depending on the library will have access to the same macros.

For more information on how to start creating macros, you can try creating a minimally viable macro project using the swift 5.9 binary:

mkdir MyMacro; cd MyMacro // create a directory first
swift package init --type macro

The commands above will create a swift package directory along with some macro definitions, implementations, client, and test.

Tests?

While apple provides assertMacroExpansion which is helpful for testing macros, it is not exactly ergonomic and pretty cumbersome to use once your macro gets complex.

        assertMacroExpansion(
"""
#stringify(a + b)
""",
expandedSource: """
"a + b"
""",
macros: testMacros
)

The assertion relies on pure string matching, and that makes it prone to formatting error. Imagine needing to fix an arbitrary whitespace in a 200 line long code!

The folks at Pointfree have made a great library for macro testing using their own snapshot testing library https://github.com/pointfreeco/swift-macro-testing. I would recommend using such library as an alternative to test your macro since it makes life easier.

That’s it!

Refs

--

--