Creating custom Swift Macros (with examples SF Symbol / URL validator / iCloud key-value store bind)

This article will talk about creating your own custom Swift Macro. It will cover ExpressionMacro and AccessorMacro. With examples of SF Symbol name validate, URL validate, and bind variable to iCloud key-value store.

This article will talk about creating your own custom Swift Macro. It will cover 2 types of Macros: ExpressionMacro which helps you expand and help you write an expression; and AccessorMacro which adds set and get to a variable. It has some examples:

  • Verify SF Symbol name and convert it to SwiftUI Image (or throw compile error if name is invalid)
  • Verifies if String is valid URL and then convert it to URL object (or throw compile time error)
  • Bind the value of a variable to that on iCloud key-value storage
💡
Swift Macro is a new feature announced in WWDC 2023. You will need Xcode 15 to test this. However, this is backward compatible with older iOS and MacOS apps (since Xcode expands Macros at compile time)

Creating a new Macro Swift Package

In the command line, go to the directory you want the package to be located at. Then, type the following:

mkdir ExampleSwiftMacro
cd ExampleSwiftMacro
swift package init --type macro

Now, you can double click on the Package.swift file to open the Swift Package in Xcode.

Besides creating this Swift Package from terminal, you can also do it in Xcode by click on the File menu, click on New, click on Package, and select "Swift Macro"

There are 2 files that are important: ExampleSwiftMacro.swift, which provides a definition of the Macro; and the ExampleSwiftMacroMacro.swift file, which provides the implementation of the Macro.

Implementation of a Macro

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))"
    }
}

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

Here, in the example Macro, it tries to read the node.argumentList, which is the input parameters to this Macro.

Also, we need to define the macro definition so you can call the above Macro in your code using the # annotation.

For example, if we call the Macro with the following function:

let (result, code) = #stringify(17 + 25)

print("The value \(result) was produced by the code \"\(code)\"")

The node.argumentList.first?.expression will be the expression a + b.

Now, the output of the above code is:

The value 42 was produced by the code "17 + 25"

The above output is produced by the return statement of the Macro implementation:

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

Notice that, \(argument) produced the result of the operation 17 + 25, and argument.description outputs the original code of that operation.

Another example

We can also set up a variable a and b for the above example.

let a = 17
let b = 25

let (result, code) = #stringify(a + b)

print("The value \(result) was produced by the code \"\(code)\"")

here is the output:

The value 42 was produced by the code "a + b"

Defining the list of provided Macros within a package

Now, we can add the above Macro to the list of Macros included in this Swift Package:

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

Implementing a Macro for converting String to URL

We can also implement a Macro to convert the String to URL. Normally, we will have to use the following code:

if let url = URL(string: "https://example.com") { ... }

If the URL string is not valid, this will not produce an error in compile time, but it will not work in run time.

However, with the help of Swift Macro, you can check the validity of the URL in compile time. The following code checks if the protocol is http or https, and if there is a valid hostname.

import Foundation
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct URLMacro: ExpressionMacro {
    
    enum URLMacroError: Error {
        case missingArgument
        case argumentNotString
        case invalidURL
        case invalidURL_Scheme
        case invalidURL_Host
    }
    
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression else {
            throw URLMacroError.missingArgument
        }
        
        guard let stringLiteralExpr = argument.as(StringLiteralExprSyntax.self),
              let segment = stringLiteralExpr.segments.first?.as(StringSegmentSyntax.self),
              stringLiteralExpr.segments.count == 1
        else {
            throw URLMacroError.argumentNotString
        }
        
        let text = segment.content.text
        
        guard let url = URL(string: text) else {
            throw URLMacroError.invalidURL
        }
        
        guard let scheme = url.scheme,
              ["http", "https"].contains(scheme) else {
            throw URLMacroError.invalidURL_Scheme
        }
        
        guard let host = url.host,
              !host.isEmpty else {
            throw URLMacroError.invalidURL_Host
        }
        
        return #"URL(string: "\#(raw: text)")!"#
    }
    
    private static func isValidURL(_ urlString: String) -> Bool {
        
        
        // You can add more conditions based on your needs
        
        return true
    }
    
}

The above Swift Macro first tries to get the first argument of the input. Then, it checks if the input is a valid String (so if you input an integer to this Macro, Xcode will not compile the code); then, it tries to convert the string to an URL, if it succeeded, it will replace the macro code with URL(string:) code; otherwise, it will not compile.

In the above code, we have also defined custom error types. So when there is an error with the code, Xcode will let us know the error code.

Now, we add the macro definition so we can call #urlFromString in our code:

@freestanding(expression)
public macro urlFromString<T>(_ value: T) -> (T, String) = #externalMacro(module: "ExampleSwiftMacroMacros", type: "URLMacro")

Notice that module is the name of the folder which contains the code file for the Macro definition. And type is the name of the struct which conforms to ExpressionMacro.

Now, we can call #urlFromString within our code:

let goodURL = #urlFromString("https://apple.com")
print(goodURL.host ?? "")

If you provide a wrong URL, the compiler will throw an error (so you will know your code has an invalid URL when compiling):

Validate SwiftUI Image SF Symbols at compile time

You can validate if SF Symbol code is valid during compile time with a Swift Macro. So, if you enter a SF Symbol code that does not exist, it will throw an error at compile time.

First, we will implement the Macro:

public struct SwiftUISystemImageMacro: ExpressionMacro {
    
    enum SFSymbolMacroError: Error {
        case missingArgument
        case argumentNotString
        case invalidSFSymbolName
    }
    
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression else {
            throw SFSymbolMacroError.missingArgument
        }
        
        guard let stringLiteralExpr = argument.as(StringLiteralExprSyntax.self),
              let segment = stringLiteralExpr.segments.first?.as(StringSegmentSyntax.self),
              stringLiteralExpr.segments.count == 1
        else {
            throw SFSymbolMacroError.argumentNotString
        }
        
        let text = segment.content.text
        
        #if os(iOS)
        guard UIImage(systemName: text) != nil else {
            throw SFSymbolMacroError.invalidSFSymbolName
        }
        #elseif os(macOS)
        guard NSImage(systemSymbolName: text, accessibilityDescription: nil) != nil else {
            throw SFSymbolMacroError.invalidSFSymbolName
        }
        #endif
        
        return #"Image(systemName: "\#(raw: text)")"#
    }
}

The above code checks if the input image name exists in the SF Symbol image (by creating an UIImage or NSImage using that image name); if it does, it will return a SwiftUI Image object with that image; otherwise, it will throw an error.

Now, we add it to the list of Macros provided in the package:

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

And we will define a shortcut for this Macro:

@freestanding(expression)
public macro systemImage(_ str: String) -> Image = #externalMacro(module: "ExampleSwiftMacroMacros", type: "SwiftUISystemImageMacro")

Now, in our SwiftUI view, instead of using Image(systemImage:) - which will show empty image at run time if we enter a wrong image name - we will get error at compile time.

Creating a Macro to add modifier to variable (example: access iCloud key-value storage)

You can have a Macro that adds read and write modifiers to a variable so that the value is accessed from iCloud key-value storage

Above is the image with expanded values. As you can see, this Macro adds the get and set modifier to this variable, and also provides a default value.

public struct NSUbiquitousKeyValueStoreMacro: AccessorMacro {
    
    enum NSUbiquitousKeyValueStoreMacroError: Error {
        case noTypeDefined
        case cannotGetBinding
        case cannotGetVariableName
    }
    
    public static func expansion(of node: AttributeSyntax,
                                 providingAccessorsOf declaration: some DeclSyntaxProtocol,
                                 in context: some MacroExpansionContext) throws -> [AccessorDeclSyntax] {
        
        let typeAttribute = node.attributeName.as(IdentifierTypeSyntax.self)
        guard let dataType = typeAttribute?.type else {
            throw NSUbiquitousKeyValueStoreMacroError.noTypeDefined
        }
        
        guard let varDecl = declaration.as(VariableDeclSyntax.self) else {
            return []
        }
        
        guard let binding = varDecl.bindings.first?.as(PatternBindingSyntax.self)else {
            throw NSUbiquitousKeyValueStoreMacroError.cannotGetBinding
        }
        
        guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else {
            throw NSUbiquitousKeyValueStoreMacroError.cannotGetVariableName
        }
        
        var defaultValue = ""
        if let value = binding.initializer?.value {
            defaultValue = " ?? \(value)"
        }
        
        let getAccessor: AccessorDeclSyntax =
          """
          get {
              (NSUbiquitousKeyValueStore.default.object(forKey: "\(raw: identifier)") as? \(raw: dataType))\(raw: defaultValue)
          }
          """
        
        let setAccessor: AccessorDeclSyntax =
          """
          set {
              NSUbiquitousKeyValueStore.default.set(newValue, forKey: "\(raw: identifier)")
          }
          """
        return [getAccessor, setAccessor]
    }
    
}

extension IdentifierTypeSyntax {
    var type: SyntaxProtocol? {
        genericArgumentClause?.arguments.first?.as(GenericArgumentSyntax.self)?.argument.as(OptionalTypeSyntax.self)?.wrappedType
        ?? genericArgumentClause?.arguments.first?.as(GenericArgumentSyntax.self)
    }
}

To implement this Macro, we will use an extension IdentifierTypeSyntax.type to read the type we have included in the <> symbol.

extension IdentifierTypeSyntax {
    var type: SyntaxProtocol? {
        genericArgumentClause?.arguments.first?.as(GenericArgumentSyntax.self)?.argument.as(OptionalTypeSyntax.self)?.wrappedType
        ?? genericArgumentClause?.arguments.first?.as(GenericArgumentSyntax.self)
    }
}

let typeAttribute = node.attributeName.as(IdentifierTypeSyntax.self)
guard let dataType = typeAttribute?.type else {
  throw NSUbiquitousKeyValueStoreMacroError.noTypeDefined
}
...

Since we have typed @iCloudKeyValue<String>, the type will be String

The identifier (key of the key-value pair) will be the same as the variable name, which we can read using the following code:

 guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else {
            throw NSUbiquitousKeyValueStoreMacroError.cannotGetVariableName
        }

Now, you can also read the default value the user provided:

        guard let binding = varDecl.bindings.first?.as(PatternBindingSyntax.self)else {
            throw NSUbiquitousKeyValueStoreMacroError.cannotGetBinding
        }

                var defaultValue = ""
        if let value = binding.initializer?.value {
            defaultValue = " ?? \(value)"
        }

We will then generate the code for set and get.

let getAccessor: AccessorDeclSyntax =
          """
          get {
              (NSUbiquitousKeyValueStore.default.object(forKey: "\(raw: identifier)") as? \(raw: dataType))\(raw: defaultValue)
          }
          """
        
        let setAccessor: AccessorDeclSyntax =
          """
          set {
              NSUbiquitousKeyValueStore.default.set(newValue, forKey: "\(raw: identifier)")
          }
          """
        return [getAccessor, setAccessor]

Set a custom compiler error message

We can also provide a custom compiler error message indicating why the error occurs. For instance, in the above String to URL function, we can show a custom message when the protocol (URL Scheme) is not http or https:

First, you need to create an enum with the cases of errors. Notice that the case can contain additional parameter to better describe the issue. For example, if the user has used a wrong protocol, you can include the protocol user used.

public enum CodingKeysMacroDiagnostic {
    case missingArgument
    case argumentNotString
    case invalidURL
    case invalidURL_Scheme(String)
    case invalidURL_Host
}

Now, you write an extension of the above enum to provide a function that generates a custom message:

extension CodingKeysMacroDiagnostic: DiagnosticMessage {
    func diagnose(at node: some SyntaxProtocol) -> Diagnostic {
        Diagnostic(node: Syntax(node), message: self)
    }
    
    public var message: String {
        switch self {
            case .missingArgument:
                return "You need to provide the argument in the parameter"
            case .argumentNotString:
                return "The argument you provided is not a String"
            case .invalidURL:
                return "Cannot initialize an URL from your provided string"
            case .invalidURL_Scheme(let scheme):
                return "\(scheme) is not a supported protocol"
            case .invalidURL_Host:
                return "The hostname of this URL is invalid"
        }
    }
    
    public var severity: DiagnosticSeverity { .error }
    
    public var diagnosticID: MessageID {
        MessageID(domain: "Swift", id: "CodingKeysMacro.\(self)")
    }
}

As you can see, we have defined custom messages by overriding the message property.

Now, in the implementation of the Swift Macro, we will add the code line to return a diagnostic information to the context:

public struct URLMacro: ExpressionMacro {
    
    enum URLMacroError: Error {
        case missingArgument
        case argumentNotString
        case invalidURL
        case invalidURL_Scheme
        case invalidURL_Host
    }
    
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression else {
            context.diagnose(CodingKeysMacroDiagnostic.missingArgument.diagnose(at: node))
            throw URLMacroError.missingArgument
        }
        
        guard let stringLiteralExpr = argument.as(StringLiteralExprSyntax.self),
              let segment = stringLiteralExpr.segments.first?.as(StringSegmentSyntax.self),
              stringLiteralExpr.segments.count == 1
        else {
            context.diagnose(CodingKeysMacroDiagnostic.argumentNotString.diagnose(at: node))
            throw URLMacroError.argumentNotString
        }
        
        let text = segment.content.text
        
        guard let url = URL(string: text) else {
            context.diagnose(CodingKeysMacroDiagnostic.invalidURL.diagnose(at: node))
            throw URLMacroError.invalidURL
        }
        
        guard let scheme = url.scheme,
              ["http", "https"].contains(scheme) else {
            context.diagnose(CodingKeysMacroDiagnostic.invalidURL_Scheme(url.scheme ?? "?").diagnose(at: node))
            throw URLMacroError.invalidURL_Scheme
        }
        
        guard let host = url.host,
              !host.isEmpty else {
            context.diagnose(CodingKeysMacroDiagnostic.invalidURL_Host.diagnose(at: node))
            throw URLMacroError.invalidURL_Host
        }
        
        return #"URL(string: "\#(raw: text)")!"#
    }
    
}

Now, when you test it, you will see the diagnostic message.

Other uses of Macro

This article introduces 2 types of Macro, ExpressionMacro which helps you complete an expression, and AccessorMacro, which helps to add modifiers to a variable.

Here is the source code for this article:

GitHub - mszpro/Swift_Macro_Qiita: Code for the Qiita article on Swift Macro, contains 3 ExpressionMacro (URL, SF Symbol, Xcode demo) and 1 AccessorMacro (access NSUbiquitousKeyValueStore)
Code for the Qiita article on Swift Macro, contains 3 ExpressionMacro (URL, SF Symbol, Xcode demo) and 1 AccessorMacro (access NSUbiquitousKeyValueStore) - GitHub - mszpro/Swift_Macro_Qiita: Code f…

There are other types of Macros:

There are also some interesting Github repositories online where you can learn more about using Macros:

GitHub - Ryu0118/CodingKeysMacro: Swift Macro that automatically generates CodingKeys for converting snake_case to lowerCamelCase.
Swift Macro that automatically generates CodingKeys for converting snake_case to lowerCamelCase. - GitHub - Ryu0118/CodingKeysMacro: Swift Macro that automatically generates CodingKeys for converti…
GitHub - LeonardoCardoso/InitMacro
Contribute to LeonardoCardoso/InitMacro development by creating an account on GitHub.