Episode #532

Property Wrappers - Accessing the Enclosing Instance

26 minutes
Published on July 7, 2022

This video is only available to subscribers. Get access to this video and 585 others.

Property Wrappers are great for encapsulating cross-cutting concerns and simplifying common patterns. However, previously it seemed impossible to access the enclosing instance to enable more advanced and useful behaviors. As it turns out, this was supported all along, just in a not-so-obvious way. Let me show you.

Property Wrappers are great for encapsulating cross-cutting concerns and simplifying common patterns.

Most of the time you see these implemented with a wrappedValue and you pass any necessary values to the initializer of the property wraper. However this means you don't have access to the enclosing instance that is using the property wrapper.

There are some scenarios in which this would be incredibly useful. Previously I thought this was a dead end for property wrappers. But I recently came across a somewhat non-obvious solution that has been supported for quite some time — since Swift 5.3 in fact.

Before we get into that, let’s first look at an example of why you might want to do this.

Let’s say we have a property wrapper to provide a nice interface to user preferences through UserDefaults:

@propertyWrapper
struct Preference<Value: UserDefaultsSerializable> {
    let key: UserDefaultsKey

    var wrappedValue: Value {
        get {
            let defaults = UserDefaults.standard
            if let value = defaults.object(forKey: key.stringValue) as? Value {
                return value
            }
            return `default`
        }
        set {
            let defaults = UserDefaults.standard
            defaults.set(newValue, forKey: key.stringValue)
        }
    }

    init(wrappedValue: Value, key: UserDefaultsKey) {
        self.key = key
        self.default = wrappedValue
    }

    init<T>(key: UserDefaultsKey) where Value == Optional<T> {
        self.key = key
        self.default = nil
    }
}

Here we have a property wrapper that is generic over some Value type, which is constrained to be UserDefaultsSerializable -- this is to safeguard against using this type without values that cannot be natively stored in UserDefaults.

The protocol is defined along with some known conformances:

protocol UserDefaultsSerializable {}

extension String: UserDefaultsSerializable {}
extension Int: UserDefaultsSerializable {}
extension Bool: UserDefaultsSerializable {}
extension Double: UserDefaultsSerializable {}
extension URL: UserDefaultsSerializable {}
extension Array: UserDefaultsSerializable where Element: UserDefaultsSerializable {}
extension Dictionary: UserDefaultsSerializable where Value: UserDefaultsSerializable {}
extension Optional: UserDefaultsSerializable where Wrapped: UserDefaultsSerializable {}

Here we have all of the primitive values we support, as well as arrays, dictionaries, or optionals of those supported types.

The next piece is the UserDefaultsKey, which is a struct that provides an extension point for defining your own keys.

struct UserDefaultsKey {
    var stringValue: String
}

With this in place we have a pretty useful property wrapper. We can use it like this:

class AppSettings: UserDefaultsProvider {
    @Preference(key: .theme) var theme: String? = nil
    @Preference(key: .notificationsEnabled) var notificationsEnabled: Bool = true
}

There’s a lot of functionality packed in those 2 lines, however it is still quite readable.

If we take this for a spin, we can see that creating an instance and setting the properties works like you’d expect, persisting the saved values in UserDefaults.

This property wrapper works, but has a pretty glaring limitation: You can only store things in UserDefaults.standard. This is good for most cases, but if you want to support shared containers (for instance sharing UserDefaults with an extension, watchOS app, etc). It also means that if we want to test this in isolation we can't use a dummy instance of UserDefaults.

Static subscripts to the rescue

Let’s change this to use a lesser-known feature of property wrappers, and that is a static subscript.

A static subscript on a property wrapper takes 3 arguments:

  • _enclosingInstance - that gives us the instance that owns this property
  • wrapped - a key path that takes us to the underlying value that is being wrapped
  • storage - a key path that takes us to the property wrapper itself
static subscript<InstanceType>(
    _enclosingInstance instance: InstanceType,
    wrapped wrappedKeyPath: KeyPath<InstanceType, Value>,
    storage storageKeyPath: KeyPath<InstanceType, Self>
  ) -> Value {
    get {
        let defaults = UserDefaults.standard
        let wrapper = instance[keyPath: storageKeyPath]
        if let value = defaults.object(forKey: wrapper.key.stringValue) as? Value {
            return value
        }
        return wrapper.default
    }
    set {
        let defaults = UserDefaults.standard
        let wrapper = instance[keyPath: storageKeyPath]
        defaults.set(newValue, forKey: wrapper.key.stringValue)
    }
}

Since we don’t know what type will actually use our property wrapper, we make this a generic argument InstanceType on the subscript.

With this we have a way to get access to the instance. What if we wanted to require that the instance is the one that provides the UserDefaults to use?

protocol UserDefaultsProvider {
    var defaults: UserDefaults { get }
}

We could then constrain the subscript to ensure that that enclosing instance conformed to this protocol:

static subscript<Instance: UserDefaultsProvider>(
    _enclosingInstance instance: Instance,
    wrapped wrappedKeyPath: KeyPath<OuterSelf, Value>,
    storage storageKeyPath: KeyPath<OuterSelf, Self>
) -> Value {

Now if we compile we’ll get an error that AppSettings doesn’t conform to this protocol. We can update it and allow this to be injected (or use a hard-coded one with a suiteName if we want).

class AppSettings: UserDefaultsProvider {
    let defaults: UserDefaults

    init(defaults: UserDefaults) {
        self.defaults = defaults
    }

    ...
}

When doing this we are requiring the enclosing type to conform to this, which now makes our wrappedValue property misleading as it will use .standard regardless of whatever the type specifies.

To avoid using this accidentally, it is a good idea to ensure that wrappedValue is not called. This would also be the case if the enclosing type was a struct.

@available(*, unavailable, message: "Enclosing type must be a class")
var wrappedValue: Value {
    get {
        fatalError()
    }
    set {
        fatalError()
    }
}

With this in place we can now have our value dynamically specified by the type that is using the property wrapper, which gives us the flexibility to share this data across extensions or use different values for testing.

Another Example

Let’s look at another example (this one courtesy of John Sundell's post on the topic). Say we have a HeaderView that has a couple of private child views:

class HeaderView: UIView {
    private var titleLabel = UILabel()
    private var imageView = UIImageView()

    var title: String? { ... }
    var image: UIImage? { ... }
}

The title and image properties simply delegate to the ones provided by the subviews. This allows us to keep the surface area of our HeaderView as small as possible, but doing so requires some boilerplate code:

var title: String? {
    get { titleLabel.text }
    set { titleLabel.text = newValue }
}

var image: UIImage? {
    get { imageView.image }
    set { imageView.image = newValue }
}

We could use a property wrapper to do this for us.

Let’s call it Proxy

@propertyWrapper
struct AnyProxy<EnclosingType, Value> {
}

We’ll need this to be initialized with a key path to the underlying value that this property provides access to:

    let keyPath: ReferenceWritableKeyPath<EnclosingType, Value>

    init(_ keyPath: ReferenceWritableKeyPath<EnclosingType, Value>) {
        self.keyPath = keyPath
    }

Note that we’re using ReferenceWritableKeyPath because we intend for the setter of our property wrapper to actually modify the target value. The keyPath goes from EnclosingType to the target Value, which is why we specified the EnclosingType as a generic argument on the struct itself.

Finally we can implement the static subscript:

    static subscript(
        _enclosingInstance instance: EnclosingType,
        wrapped wrappedKeyPath: KeyPath<EnclosingType, Value>,
        storage storageKeyPath: KeyPath<EnclosingType, Self>
    ) -> Value {
        get {
            let wrapper = instance[keyPath: storageKeyPath]
            return instance[keyPath: wrapper.keyPath]
        }
        set {
            let wrapper = instance[keyPath: storageKeyPath]
            instance[keyPath: wrapper.keyPath] = newValue
        }
    }

And just like before, we can’t implement this at all via the wrappedValue property, since we need access to the enclosing instance for this to work at all, so we mark that as unavailable.

    @available(*, unavailable, message: "@Proxy can only be applied to classes")
    var wrappedValue: Value {
        get { fatalError() }
        set { fatalError() }
    }

Now our header view can be written as:

class HeaderView: UIView {
    private var titleLabel = UILabel()
    private var imageView = UIImageView()

    @Proxy(\HeaderView.titleLabel.text) var title: String?
    @Proxy(\HeaderView.imageView.image) var image: UIImage?
}

One issue w/ this approach is that we have to specify the full key path including the type name. The compiler unfortunately cannot infer that this is what we mean.

To get around this, we can employ a small compiler trick to get it to recognize the root type of the keypath is the same as Self. We'll first rename Proxy to AnyProxy, then define a way for the root parameter to be generalized to Self:

protocol ProxyContainer {
    typealias Proxy<T> = AnyProxy<Self, T>
}

Then we can add a default conformance to most types that we might want to work with:

extension NSObject: ProxyContainer {}

Doing this we can now omit the root type and have the compiler infer it:

class HeaderView: UIView {
    private var titleLabel = UILabel()
    private var imageView = UIImageView()

    @Proxy(\.titleLabel.text) var title: String?
    @Proxy(\.imageView.image) var image: UIImage?
}

So that’s a look at how we can access the enclosing instance with property wrappers and unlock a bunch of new possibilities.

References

This episode uses Swift 5.7.