What’s the difference between Macros and property wrappers?

Published on: June 6, 2023

With Swift 5.9 and Xcode 15, we have the ability to leverage Macros in Swift. Macros can either be written with at @ prefix or with a # prefix, depending on where they're being used. If you want to see some examples of Macros in Swift, you can take a look at this repository that sheds some light on both usage and structure of Macros.

When we look at Macros in action, they can look a lot like property wrappers:

@CustomCodable
struct CustomCodableString: Codable {

  @CodableKey(name: "OtherName")
  var propertyWithOtherName: String

  var propertyWithSameName: Bool

  func randomFunction() {

  }
}

The example above comes from the Macro examples repository. With no other context it's hard to determine whether CodableKey is a property wrapper or a Macro.

One way to find out is to option + click on a Macro which should bring up a useful dialog in Xcode that will make it clear that you're looking at a Macro.

Given how similar Macros and property wrappers look, you might be wondering whether Macros replace property wrappers. Or you might think that they're basically the same thing just with different names.

In reality, Macros are quite different from property wrappers. The key difference is when and where they affect your code and your app.

Property wrappers are executed at runtime. This means that any extra logic that you've added in your property wrapper is applied to your wrapped value while your app is running. This is powerful when you need to manipulate or work with wrapped values in a dynamic fashion.

Macros on the other hand are executed at compile time and they allow us to augment our code by rewriting or expanding code. In other words, Macros allow us to add, rewrite, and modify code at compile time.

For example, there's a #URL Macro that we can use in Xcode 15 to get non-optional URL objects that are validated at compile time. There's also an @Relationship Macro in Swift Data that allows us to generate all code that's needed to define a relationship between two models.

Without digging too deep in different kinds of Macros and how they are defined, the difference is that a Macro defined with a # sign are freestanding. This means that they generate code on their own and aren't applied to an object or property. Macros defined with an @ are applied to something and can't exist on their own like a freestanding Macro can.

Exploring Macros in-depth is a topic for another post.

We can even apply Macros to entire objects like when you apply the @Observable or @Model Macros to your model definitions. Applying a Macro to an object definition is very powerful and allows us to add tons of features and functionality to the object that the Macro is applied to.

For example, when we look at the @Model Macro we can see that it takes code defined like this:

@Model
final class Item {
    var timestamp: Date

    init(timestamp: Date) {
        self.timestamp = timestamp
    }
}

And transforms it into this:

@Model
final class Item {
    @PersistedProperty
    var timestamp: Date
    {
        get {
            _$observationRegistrar.access(self, keyPath: \.timestamp)
            return self.getValue(for: \.timestamp)
        }

        set {
            _$observationRegistrar.withMutation(of: self, keyPath: \.timestamp) {
                self.setValue(for: \.timestamp, to: newValue)
            }
        }
    }

    init(timestamp: Date) {
        self.timestamp = timestamp
    }

    @Transient
    public var backingData: any BackingData<Item> = CoreDataBackingData(for: Item.self)

    static func schemaMetadata() -> [(String, AnyKeyPath, Any?, Any?)] {
      return [
        ("timestamp", \Item.timestamp, nil, nil)
      ]
    }

    init(backingData: any BackingData<Item>, fromFetch: Bool = false) {
      self.backingData = backingData
      if !fromFetch {
        self.context?.insert(object: self)
      }
    }

    @Transient
    private let _$observationRegistrar = ObservationRegistrar()
}

extension Item : PersistentModel  {}

extension Item : Observable  {}

Notice how much more code that is, and imagine how tedious it would be to write and manage all this code for every Swift Data model or @Observable object you create.

Macros are a real powerhouse, and they will enable us to write shorter, more concise, and less boilerplate-heavy code. I'm excited to see where Macros go, and how they will make their way into more and more places of Swift.

Conclusion

As you learned in this post, the key difference between Macros and property wrappers in Swift is that Macros are evaluated at compile time while property wrappers are useful at runtime. This means that we can use Macros to generate code on our behalf while we compile our app and property wrappers can be used to change behavior and manipulate properties at runtime.

Even though they both share the @ annotation (and Macros can also have the # annotation in some cases), they do not cover the same kinds of features as you now know.

Cheers!

Categories

Quick Tip Swift

Subscribe to my newsletter