Mario's tech stuff

Swift Property Wrappers: a practical introduction

Property wrappers, also known as property delegates, have been introduced in Swift 5.1 and new frameworks like SwiftUI and Combine heavily rely on them, hence the importance of understanding how they works.

What is exactly a property wrapper?

A property wrapper is a type that can wrap a property adding functionalities without changing the type of the wrapped property. Some examples defined in the frameworks mentioned above are @State, @ObservedObject and @Published.
An example in the real world would be an invisible box around a ball that plays a note of the Beethoven ninth symphony every time the ball is kicked without changing the ball physical properties (mass, density, etc.).

In order to better understand how they work let’s try to build a form that allows users to type an amount of money and send them to another user.
Assuming the amount can’t be less than 0 or more than 1000, the code that solves the problem without property wrappers can roughly look like this

class TypeAmountViewController: UIViewController {

    @IBOutlet private var amountTextField: UITextField!

    private var amount = 0

    @IBAction func confirmAmount() {
        amountTextField.resignFirstResponder()
        validateAmount()
    }

    private func validateAmount() {
        guard
            let typedAmount = Int(amountTextField.text ?? "")
            else {
                showErrorAlert()
                return
            }
        amount = min(1_000, max(0, typedAmount))
        if amount > 0 {
            showConfirmAmountAlert()
        }
    }

    private func showConfirmAmountAlert() {
        print("Are you sure to send \(amount)?")
    }

    private func showErrorAlert() {
        print("The value you typed doesn't look like a number")
    }

    private func handleConfirmedAmount() {
        print("You sent \(amount)!")
    }
}

A downside of this approach is that the clamping logic is embedded in the view controller making it difficult to reuse the code.
To solve this problem it is possible to introduce a new type

struct Amount {
    let value: Int

    init(value: Int, range: Range) {
        self.value = min(range.upperBound, max(range.lowerBound, value))
    }
}

And the view controller will now look like this

class TypeAmountViewController: UIViewController {

    @IBOutlet private var amountTextField: UITextField!

    private var amount = Amount(value: 0, range: 0..<1_000)

    @IBAction func confirmAmount() {
        amountTextField.resignFirstResponder()
        validateAmount()
    }

    private func validateAmount() {
        guard
            let typedAmount = Int(amountTextField.text ?? "")
            else {
                showErrorAlert()
                return
            }
        amount = Amount(value: typedAmount, range: 0..<1_000)
        if amount.value > 0 {
            showConfirmAmountAlert()
        }
    }

    private func showConfirmAmountAlert() {
        print("Are you sure to send \(amount.value)?")
    }

    private func showErrorAlert() {
        print("The value you typed doesn't look like a number")
    }

    private func handleConfirmedAmount() {
        print("You sent \(amount.value)!")
    }
}

The main pitfall of this code is that amount is no more a Int, therefore it is not possible to sum, compare and perform all the other operations on it allowed with other integers, plus in order to obtain the real amount we need to read the property amount.value

Lets see how it is possible to use property wrappers to solve this issue.
In order to tell the compiler that “a type is a property wrapper” we need to:

  1. Add the keyword @propertyWrapper before the type declaration
  2. Declare at least one wrappedValue property.

In the example case we need to wrap an integer amount, therefore wrappedValue is an Int.
In the init we still need the range to be sure the amount doesnt exceed our requirements.

The final result will look like this:

@propertyWrapper // <- the type `Amount` is a property wrapper
struct Amount {
     
    // every time amount is set (eg: amount = 5), amount.wrappedValue is set (amount.wrappedValue = 5)
    var wrappedValue: Int { // <- the type we need to wrap is an Int
        set {
            costrainedValue = min(range.upperBound, max(range.lowerBound, newValue))
        }
        get {
            costrainedValue
        }
    }

    private var costrainedValue: Int
    private let range: Range

    init(wrappedValue: Int, range: Range) {
        self.costrainedValue = wrappedValue
        self.range = range
    }

}

Now it is possible to treat amount as an Int while still limit its value using the following declarative API

@Amount(range: 0..<1_000)
private var amount = 0 

As you can the init takes two parameters as input (one wrappedValue: Int and one range: Range<Int>) and in the declaration we only specify the range but the compiler is smart enough to understand that our wrapped value is 0.

It is possible to use the property wrapper in our view controller like this

class TypeAmountViewController: UIViewController {

    @IBOutlet private var amountTextField: UITextField!

    @Amount(range: 0..<1_000)
    private var amount = 0

    @IBAction func confirmAmount() {
        amountTextField.resignFirstResponder()
        validateAmount()
    }

    private func validateAmount() {
        guard
            let typedAmount = Int(amountTextField.text ?? "")
            else {
                showErrorAlert()
                return
            }
        self.amount = typedAmount
        if amount > 0 {
            showConfirmAmountAlert()
        }
    }

    private func showConfirmAmountAlert() {
        print("Are you sure to send \(amount)?")
    }

    private func showErrorAlert() {
        print("The value you typed doesn't look like a number")
    }

    private func handleConfirmedAmount() {
        print("You sent \(amount)!")
    }
}

Now it is possible to use @Amount anywhere in the code where we need an integer property constrained between two values but what about Float or Double numbers? Do we need to write a property wrapper for each type we want to wrap?

Of course not. One of the best features of Swift are generics, in the next posts I'll show you how to use generics and how to build a generic property wrapper.