Mastering Swift and SwiftUI

Insights, Tips, and Tutorials for iOS Developers

How to Build the @Entry Macro in SwiftUI

Published on Sep 6, 2024

Get a quick glimpse of how Tiny Currency simplifies currency conversion with up to date rates, multi-currency support, and easy-to-use widgets. Perfect for on-the-go use, our app ensures you're always prepared, no matter where your travels take you.

📱 New in iOS 18.0+

The @Entry macro in SwiftUI has recently sparked a lot of interest among developers, bringing a new streamlined way to manage entries specifically for environment values, transaction, container values, or focused values.

The macro is pretty new, but we've already seen some articles from prominent authors in the community:

@Entry Expanded

The first two articles give us a good idea of how to use the new @Entry macro, but SwiftLee goes into a bit more detail about what the expanded macro looks like. Here's the example that Apple provides in their documentation for EnvironmentValues

extension EnvironmentValues {
    @Entry var myCustomValue: String = "Default value"
}

The macro expands to add some code, however it's important to note that this macro is defined as actually composed of 2 macros. Let's look at the actual macro definition:

@attached(accessor) 
@attached(peer, names: prefixed(__Key_)) 
public macro Entry() = #externalMacro(module: "SwiftUIMacros", type: "EntryMacro")

As you can see above, the macro is defined as both an accessor macro and a peer macro. To summarize, an attached macro is something that can "see" and read the code to which it's attached.

➡️ The accessor macro can create accessors (getters and setters as an example).

➡️ The peer macro can produce new declarations alongside the declaration to which they are attached.

For in-depth information on attached macros, take a look at the Swift evolution propsal.

Let's see what these macros look like when they are expanded:

extension EnvironmentValues {
    @Entry var myCustomValue: String = "Default value"
    
    // 1. The accessor macro expands here
    { 
        get {
            self[__Key_myCustomValue.self]
        }
        set {
            self[__Key_myCustomValue.self] = newValue
        }
    }
    
    // 2. The peer macro expands here
    private struct __Key_myCustomValue: SwiftUICore.EnvironmentKey {
        typealias Value = String
        static var defaultValue: Value { "Default value" }
    }
}

The order of the macro expansion here does not matter. These macros have no knowledge of each other, and can be expanded simultaneously and independently. To really understand what's happening here at a deeper level, let's attempt to recreate the macro. Xcode 16 provides a nice template to create a new macro package to help us get started.

Creating the macro

First, let's create the package and define a new macro similar to @Entry. I am naming mine @Resolved.

@attached(accessor)
@attached(peer, names: prefixed(__Key_))
public macro Resolved() = #externalMacro(module: "ResolvedMacros", type: "ResolvedMacro")

As you can see, the package module that was automatically created by Xcode is called "ResolvedMacros" and I have created a type in there called ResolvedMacro.

Let's define that type:

public enum ResolvedMacro { }

My new macro is a simple enum for now. Let's start extending functionality. First, let's extend the AccessorMacro protocol and return an empty array.

Accessor macro

extension ResolvedMacro: AccessorMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingAccessorsOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [AccessorDeclSyntax] {
        return []
    }
}

We now have a macro that does nothing. We can test the macro using the built-in testing system provided in the macro template, or try it out in the client module.

SwiftUI's @Entry macro is limited to working only on specified types. I won't add support for all those types, but let's throw an error if the macro is not attached to SwiftUI's EnvironmentValues. We can inspect the context of the macro like so:

let extensionType = context.lexicalContext.first?
    .as(ExtensionDeclSyntax.self)?.extendedType
    .as(IdentifierTypeSyntax.self)

guard extensionType?.name.text == "EnvironmentValues" else {
    throw MacroExpansionErrorMessage("Resolved macro must be applied to EnvironmentValues")
}

The macro now inspects the lexicalContext and validates that the macro is within an extension of EnvironmentValues. If that check fails, we throw an error.

Now let's add the accessors.

guard let identifier = declaration.as(VariableDeclSyntax.self)?.bindings.first?.pattern else {
    throw MacroExpansionErrorMessage("Unable to resolve variable identifier")
}
let getAccessor = AccessorDeclSyntax(accessorSpecifier: .keyword(.get)) {
    "self[__Key_\(identifier).self]"
}
let setAccessor = AccessorDeclSyntax(accessorSpecifier: .keyword(.set)) {
    "self[__Key_\(identifier).self] = newValue"
}

First, we get the identifier of the node itself (in this case "myCustomValue"), and use it within the key. You can use tokens directly using string interpolation which is handy, rather than diving deeper into the syntax tree to get the raw string value. There are many ways to use SwiftSyntax to build tokens, I personally prefer to use the type safe accessorSpecifier, but then provide the code that I need using strings.

And that's it! We've created a macro that adds get and set accessors using specific keys, here's the entire thing:

extension ResolvedMacro: AccessorMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingAccessorsOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [AccessorDeclSyntax] {
        let extensionType = context.lexicalContext.first?
            .as(ExtensionDeclSyntax.self)?.extendedType
            .as(IdentifierTypeSyntax.self)
        guard extensionType?.name.text == "EnvironmentValues" else {
            throw MacroExpansionErrorMessage("Resolved macro must be applied to EnvironmentValues")
        }
        
        guard let identifier = declaration.as(VariableDeclSyntax.self)?.bindings.first?.pattern else {
            throw MacroExpansionErrorMessage("Unable to resolve variable identifier")
        }
        
        let getAccessor = AccessorDeclSyntax(accessorSpecifier: .keyword(.get)) {
            "self[__Key_\(identifier).self]"
        }
        let setAccessor = AccessorDeclSyntax(accessorSpecifier: .keyword(.set)) {
            "self[__Key_\(identifier).self] = newValue"
        }
        return [getAccessor, setAccessor]
    }
}

Peer macro

Let's continue on to add the peer macro, we can reuse the same checks for the identifier and extension type, I'm going to toss them into private helper functions and return nothing for now. I also added a new error if there is no default value, to mimic @Entry behavior.

extension ResolvedMacro: PeerMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingPeersOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        try validateExtensionType(context: context)
        let patternBinding = try patternBinding(for: declaration)
        guard let initializer = patternBinding.initializer else {
            throw MacroExpansionErrorMessage("A default value is required")
        }        
        return []
    }
}

Let's finally add the peer struct intself:

extension ResolvedMacro: PeerMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingPeersOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        try validateExtensionType(context: context)
        let patternBinding = try patternBinding(for: declaration)
        guard let initializer = patternBinding.initializer else {
            throw MacroExpansionErrorMessage("A default value is required")
        }
        let peerStruct = try StructDeclSyntax("private struct __Key_\(patternBinding.pattern): SwiftUICore.EnvironmentKey") {
            if let type = patternBinding.typeAnnotation?.type {
                "typealias Value = \(type)"
                "static var defaultValue: Value { \(initializer.value) }"
            } else {
                "static let defaultValue = \(initializer.value)"
            }
        }
        return [DeclSyntax(peerStruct)]
    }
}

Welcome to macro development

And that's it! You have now created an attached macro that adds accessors and a peer declaration. Take a deeper dive into macro development and see what kind of powerful macros you can create!

Don't forget, it's a good idea to thoroughly test your macros. Use the provided template to add some unit tests to make sure your macros are expanding as expected.