Can't use Property Wrappers on a class marked with @Observable

436 Views Asked by At

I have a class which is similar to:

final class Person {
    @Resettable var name = "John Doe"
    
    func revertName() {
        name = $name
    }
}

@propertyWrapper
struct Resettable<T> {
    var wrappedValue: T
    let projectedValue: T
    
    init(wrappedValue: T) {
        self.wrappedValue = wrappedValue
        self.projectedValue = wrappedValue
    }
}

However, when I annotate Person with the new @Observable macro I got the following error:

Property wrapper cannot be applied to a computed property

I'm assuming this happens because the @Observable macro adds a getter and setter to all properties in a class.

Is there any way I can use my @Resettable property wrapper and the @Observable macro at the same time?

Context: In my case it's class GameScene: SKScene and I use @Resettable to reset things like var runningSpeed and whatnot. The game scene must be marked with @Observable because the GameScene is modified using a SwiftUI view called GameSettingsView. This view uses @Bindable and @Observable to modify the game scene.

1

There are 1 best solutions below

0
Sweeper On BEST ANSWER

You are correct - @Observable turns the stored properties into computed properties, so you cannot put property wrappers on them.

You can try turning your property wrapper also into a macro. For your Resettable, this is rather simple. You just generate a new property, prefixed with $ with the same initialiser.

A simple implementation I wrote (I'm rather bad at using SwiftSyntax, so please excuse me)

public enum Resettable: PeerMacro {
    public static func expansion(of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
        // only apply to vars
        guard let varDecl = declaration.as(VariableDeclSyntax.self),
            varDecl.bindingSpecifier.text == "var" else {
            return []
        }
        return varDecl.bindings.filter {
            // apply only to those with initialisers
            $0.initializer != nil && 
            // and also the name of the variable does not start with underscore
            !$0.pattern.as(IdentifierPatternSyntax.self)!.identifier
                .text.starts(with: "_") 
        }.map {
            return "let $\($0.pattern) = \($0.initializer!.value)" as DeclSyntax
        }
    }
}

@main
struct ResettablePlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [Resettable.self]
}

// ...

@attached(peer, names: prefixed(`$`))
public macro Resettable() = #externalMacro(module: "SomeModule", type: "Resettable")

Notes:

  • I had to filter out the variables whose name start with underscore, because @Observation apparently generates a @Resettable var _name, and we don't want to add a $_name.
  • The initialiser will be run twice, so make sure it doesn't have side effects.