Approaching Wrappers for Swift Properties

To put it simply, a property wrapper acts like a container, a generic structure managing how a property is accessed (read and written) and introduces additional functionalities. It’s useful when we want to set limits on the values a property can hold, incorporate extra actions during reading or writing (like interacting with databases or user preferences), or add extra methods to the property.

Property Wrappers in Swift 5.1

This piece explores a new method introduced in Swift 5.1 for handling property wrappers. This new approach utilizes a cleaner and more intuitive syntax.

Looking Back: The Old Way

Let’s say you’re creating an application, and you have an object that stores user profile information.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct Account {
    var firstName: String
    var lastName: String
    var email: String?
}

let account = Account(firstName: "Test",
                      lastName: "Test",
                      email: "test@test.com")

account.email = "new@test.com"
print(account.email)

You decide to implement email verification. If the email address provided by the user isn’t valid, the email property should be set to nil. This scenario presents a perfect opportunity to employ a property wrapper to manage this logic.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct Email<Value: StringProtocol> {
    private var _value: Value?
    
    init(initialValue value: Value?) {
        _value = value
    }
    
    var value: Value? {
        get {
            return validate(email: _value) ? _value : nil
        }
        
        set {
            _value = newValue
        }
    }
    
    private func validate(email: Value?) -> Bool {
        guard let email = email else { return false }
        let regex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-za-z]{2,64}"
        let pred = NSPredicate(format: "SELF MATCHES %@", regex)
        return pred.evaluate(with: email)
    }
}

We can then utilize this wrapper within our Account structure:

1
2
3
4
5
struct Account {
    var firstName: String
    var lastName: String
    var email: Email<String>
}

Now we have the assurance that the email property will only hold a valid email address.

Everything seems to be working correctly, except for one aspect: the syntax.

1
2
3
4
5
6
let account = Account(firstName: "Test",
                      lastName: "Test",
                      email: Email(initialValue: "test@test.com"))

account.email.value = "new@test.com"
print(account.email.value)

When using a property wrapper, the process of initializing, reading, and writing to such properties becomes more complex. This begs the question: is it possible to bypass this complexity and use property wrappers without altering the syntax? With the introduction of Swift 5.1, the answer is yes.

Embracing the New: The @propertyWrapper Annotation

Swift 5.1 offers a more elegant approach to creating property wrappers. This approach permits the use of a @propertyWrapper annotation to mark a property wrapper. These annotated wrappers benefit from a more concise syntax compared to their traditional counterparts, leading to code that is both compact and easier to understand. The @propertyWrapper annotation comes with a single requirement: your wrapper object must include a non-static property named wrappedValue.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@propertyWrapper
struct Email<Value: StringProtocol> {
    var value: Value?

    var wrappedValue: Value? {
        get {
            return validate(email: value) ? value : nil
        }
        set {
            value = newValue
        }
    }
    
    private func validate(email: Value?) -> Bool {
        guard let email = email else { return false }
        let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
        let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx)
        return emailPred.evaluate(with: email)
    }
}

To define such a wrapped property in our code, we’ll utilize this new syntax.

1
2
@Email
var email: String?

As you can see, we’ve marked the property using the @ annotation. It’s essential that the property type aligns with the type of the wrappedValue within the wrapper. Now you can interact with this property in the same way you would with a regular property.

1
2
3
4
email = "valid@test.com"
print(email) // test@test.com
email = "invalid"
print(email) // nil

This already looks much cleaner than the old approach. However, our current wrapper implementation has a drawback: it doesn’t allow for setting an initial value for the wrapped value.

1
2
@Email
var email: String? = "valid@test.com" //compilation error.

To address this, we can add the following initializer to our wrapper:

1
2
3
init(wrappedValue value: Value?) {
    self.value = value
}

And that’s all there is to it.

1
2
3
4
5
6
7
@Email
var email: String? = "valid@test.com"
print(email) // test@test.com

@Email
var email: String? = "invalid"
print(email) // nil

Here’s the complete code for our wrapper:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@propertyWrapper
struct Email<Value: StringProtocol> {
    var value: Value?
    init(wrappedValue value: Value?) {
        self.value = value
    }
    var wrappedValue: Value? {
        get {
            return validate(email: value) ? value : nil
        }
        set {
            value = newValue
        }
    }
    
    private func validate(email: Value?) -> Bool {
        guard let email = email else { return false }
        let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
        let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx)
        return emailPred.evaluate(with: email)
    }
}

Configurable Wrappers: Adding Flexibility

Let’s consider another scenario. Imagine you’re developing a game, and you have a property responsible for storing the user’s score. The requirement is that this score must always fall within the range of 0 to 100 (inclusive). You can enforce this rule using a property wrapper.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@propertyWrapper
struct Scores {
    private let minValue = 0
    private let maxValue = 100
    private var value: Int
    init(wrappedValue value: Int) {
        self.value = value
    }
    var wrappedValue: Int {
        get {
            return max(min(value, maxValue), minValue)
        }
        set {
            value = newValue
        }
    }
}

@Scores
var scores: Int = 0

While this code functions as intended, it lacks a certain level of generality. Reusing it with different constraints (values other than 0 and 100) is not possible. Furthermore, it only works with integer values. It would be more versatile to have a configurable wrapper capable of handling any type that conforms to the Comparable protocol. To achieve this configurability, we need to introduce all configuration parameters through an initializer. It’s important to note that if the initializer includes a wrappedValue attribute (representing the initial value of our property), it should be positioned as the first parameter.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@propertyWrapper
struct Constrained<Value: Comparable> {
    private var range: ClosedRange<Value>
    private var value: Value
    init(wrappedValue value: Value, _ range: ClosedRange<Value>) {
        self.value = value
        self.range = range
    }
    var wrappedValue: Value {
        get {
            return max(min(value, range.upperBound), range.lowerBound)
        }
        set {
            value = newValue
        }
    }
}

To initialize a wrapped property, we define all configuration attributes within parentheses following the annotation.

1
2
@Constrained(0...100)
var scores: Int = 0

There’s no limit to the number of configuration attributes you can define. They need to be specified within the parentheses, following the same order as in the initializer.

Accessing the Wrapper Directly

In cases where you require access to the wrapper itself (not just the wrapped value), you can prefix the property name with an underscore. Let’s revisit our Account structure as an example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct Account {
    var firstName: String
    var lastName: String
    @Email
    var email: String?
}

let account = Account(firstName: "Test",
                      lastName: "Test",
                      email: "test@test.com")

account.email // Wrapped value (String)
account._email // Wrapper(Email<String>)

We need to access the wrapper directly to leverage the additional functionality we’ve incorporated. Let’s say we want our Account structure to conform to the Equatable protocol. Two accounts should be considered equal if their email addresses are equal, with a case-insensitive comparison for email addresses.

1
2
3
4
5
extension Account: Equatable {
    static func ==(lhs: Account, rhs: Account) -> Bool {
	 return lhs.email?.lowercased() == rhs.email?.lowercased()
    }
}

While this approach works, it’s not ideal. It requires us to remember to apply the lowercased() method every time we compare emails. A more streamlined solution involves making the Email structure itself Equatable:

1
2
3
4
5
extension Email: Equatable {
    static func ==(lhs: Email, rhs: Email) -> Bool {
	 return lhs.wrappedValue?.lowercased() == rhs.wrappedValue?.lowercased()
    }
}

and then comparing the wrappers directly instead of the wrapped values:

1
2
3
4
5
extension Account: Equatable {
    static func ==(lhs: Account, rhs: Account) -> Bool {
	 return lhs._email == rhs._email
    }
}

Projected Value: Adding Another Layer

The @propertyWrapper annotation offers an additional syntactic convenience known as a projected value. This property is not restricted to a specific type and can be defined as needed. To access this property, simply prefix the property name with a $. To illustrate its usage, let’s borrow an example from the Combine framework.

The @Published property wrapper is designed to create a publisher for a property and expose it as a projected value.

1
2
3
4
5
@Published
var message: String

print(message) // Print the wrapped value
$message.sink { print($0) } // Subscribe to the publisher

As demonstrated, we use message to interact with the wrapped property, while $message provides access to the publisher. Adding a projected value to your wrapper is straightforward; simply declare it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@propertyWrapper
struct Published<Value> {
    private let subject = PassthroughSubject<Value, Never>()
    var wrappedValue: Value {
	didSet {
	    subject.send(wrappedValue)
	}
    }
    var projectedValue: AnyPublisher<Value, Never> {
	subject.eraseToAnyPublisher()
    }
}

As previously mentioned, you have the flexibility to assign any type to the projectedValue property, depending on your requirements.

Limitations: Points to Consider

While the new property wrapper syntax offers enhanced readability, it comes with certain limitations. The primary ones are:

  1. Error Handling: They cannot directly participate in error handling mechanisms. Because the wrapped value is a property (not a method), we cannot mark its getter or setter with the throws keyword. For instance, in our Email example, we cannot throw an error if the user attempts to set an invalid email address. Our options are limited to returning nil or terminating the app using fatalError(), which might not be suitable in all situations.

  2. Multiple Wrappers: Applying multiple wrappers to a single property is not permitted. For example, it would be cleaner to have a separate @CaseInsensitive wrapper and combine it with our @Email wrapper, rather than incorporating case insensitivity within @Email. However, such constructs are not allowed and will result in compilation errors.

1
2
3
@CaseInsensitive
@Email
    	var email: String?

One workaround for this specific scenario is to inherit the Email wrapper from the CaseInsensitive wrapper. However, inheritance itself has limitations: only classes support inheritance, and a class can only inherit from a single base class.

Conclusion: Weighing the Pros and Cons

The use of @propertyWrapper annotations significantly improves the readability of property wrapper syntax, allowing us to work with wrapped properties in a manner similar to regular properties. This leads to code that is more concise and easier to comprehend, which is always a plus for Swift developers. However, it’s crucial to be mindful of the limitations associated with this approach. Hopefully, some of these limitations will be addressed in future versions of Swift.

If you’re interested in delving deeper into Swift properties, be sure to check out the official docs.

Licensed under CC BY-NC-SA 4.0