Skip to Content
Course content

136: Writing an Attached Macro

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've lost count of how many times I've written a print("User: \(name), Email: \(email), ID: \(id)") statement just to see what's happening inside a struct during a debugging session. It's tedious, it's boilerplate, and it's exactly the kind of repetition that Swift macros were designed to kill. Today, we're going to build an attached macro called @Logged. When we slap this on a struct, it'll automatically generate a log() method that prints out all the stored properties of that struct.

Designing our @Logged macro

Since we want to add a new method to an existing type, we need a Member Macro. In the macro declaration, I'll specify that this is an attached(member) macro. I'm also going to give it a specific name—log—so the compiler knows exactly what this macro intends to produce. This helps with autocomplete and prevents the compiler from guessing.

@attached(member, names: named(log))
public macro Logged() = #externalMacro(module: "MyMacroImplementation", type: "LoggedMacro")

Digging into SwiftSyntax

Now we move over to the implementation side. This is where things get a bit dense because we're dealing with SwiftSyntax. We aren't writing Swift code; we're writing code that generates Swift code. I need to implement the MemberMacro protocol, specifically the expansion function. My goal here is to look at the members of the struct and build a string of print statements.

I'll start by filtering the members to find only the variables. I don't want to try and log methods or other nested types—that would be a mess.

public struct LoggedMacro: MemberMacro {
    public static func expansion(
        of node: some DeclGroupSyntax,
        providingMembersOf declaration: some DeclSyntax
    ) throws -> [DeclSyntax] {
        // Find all variable declarations in the struct
        let variables = node.memberBlock.members.compactMap { member in
            if let varDecl = member.as(VariableDeclSyntax.self) {
                return varDecl
            }
            return nil
        }
        
        // We'll build our function body here
        let properties = variables.map { varDecl in
            // Extract the variable name
            let name = varDecl.bindings.first?.pattern.as(IdentifierPatternSyntax.self)?.identifier.text ?? "unknown"
            return "\(name): \\(\(name))"
        }.joined(separator: ", ")

        return [
            "func log() { print(\"Log: \(properties)\") }"
        ]
    }
}

The 'Oops' moment: Handling types

I ran this against a simple User struct, and it worked great. But then I tried it on a struct that had an optional property, and I realized I'd made a classic mistake: I was assuming the properties I was capturing were simple identifiers. When I added a property with a default value or a complex type annotation, my compactMap logic started acting up, and the generated string was occasionally pulling in the type annotation instead of just the name.

The problem was that VariableDeclSyntax is a tree, not a string. I was grabbing the pattern, but I wasn't being strict enough about extracting just the identifier. If the variable had an attribute or a complex binding, my "unknown" fallback was triggering, or worse, it was producing invalid Swift code that wouldn't compile in the client target.

I fixed this by drilling deeper into the IdentifierPatternSyntax. I also realized that if the struct is empty, my log() function would print "Log: ", which is useless. I added a guard to ensure we actually have properties before generating the method.

// Fixed logic inside the expansion function
guard !variables.isEmpty else { return [] }

let propertyStrings = variables.compactMap { varDecl in
    for binding in varDecl.bindings {
        if let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text {
            return "\(identifier): \\(\(identifier))"
        }
    }
    return nil
}

let body = propertyStrings.joined(separator: ", ")
return ["func log() { print(\"Log: \(body)\") }"]

Connecting the macro to the client

Now that the implementation is robust, the usage is the best part. I just apply the attribute to any model I'm working with. Because it's an attached macro, the log() method doesn't exist in my source code, but it's available to the compiler and the IDE.

@Logged
struct Project {
    let name: String
    let budget: Int
    let isInternal: Bool
}

let myProject = Project(name: "Mars Colony", budget: 1000000, isInternal: false)
myProject.log() 
// Prints: Log: name: Mars Colony, budget: 1000000, isInternal: false

It's a small win, but it's a huge quality-of-life improvement. Instead of manually updating a print statement every time I add a field to my struct, the macro handles the bookkeeping for me.




📋 Practical Task

Implement the @AutoInit macro

Your task is to create an attached member macro called @AutoInit. Instead of a logging method, this macro should generate a convenience initializer for a struct that sets all its stored properties to "default" values.

  • For String properties, the default should be "".
  • For Int properties, the default should be 0.
  • For Bool properties, the default should be false.

Requirements:

  1. Define the macro as an attached(member) macro.
  2. In the implementation, iterate through the VariableDeclSyntax members.
  3. Inspect the type of the variable (you'll need to look at the typeAnnotation) to determine which default value to use.
  4. Generate a init() method that assigns these values to self.
  5. Ensure that if a property's type is not String, Int, or Bool, it is simply skipped in the initializer.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.