Property Wrappers in Swift: Advanced Use Cases and Tips
Swift’s property wrappers, introduced in Swift 5.1, provide a powerful mechanism for encapsulating and reusing property-related logic. They…
Property Wrappers in Swift: Advanced Use Cases and Tips
Swift’s property wrappers, introduced in Swift 5.1, provide a powerful mechanism for encapsulating and reusing property-related logic. They allow developers to write cleaner, more maintainable code by applying custom behaviors to properties with minimal boilerplate. In this blog post, we’ll explore the basics of creating property wrappers and delve into advanced use cases.
What Are Property Wrappers?
Property wrappers in Swift are custom types that add additional functionality to properties. They encapsulate logic that can be reused across different properties, allowing for a declarative way to modify property behavior, such as managing state, validating inputs, or handling asynchronous operations.
Creating a Simple Property Wrapper
Let’s start with a simple example: creating a Clamped
property wrapper that ensures a value is always within a specified range.
@propertyWrapper
struct Clamped<Value: Comparable> {
private var value: Value
private let range: ClosedRange<Value>
init(wrappedValue: Value, _ range: ClosedRange<Value>) {
self.range = range
self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
}
var wrappedValue: Value {
get { value }
set { value = min(max(newValue, range.lowerBound), range.upperBound) }
}
}
Structure Overview
• Generic Parameter: The Clamped struct is generic over a type Value that conforms to the Comparable protocol. This allows you to use the property wrapper with any comparable type, such as integers, floating-point numbers, etc.
• Stored Properties:
• value: This stores the actual value that is being wrapped. It’s private, meaning it can only be accessed and modified through the wrappedValue computed property.
• range: This is a ClosedRange of Value, representing the inclusive lower and upper bounds for the value. The range is also private.
Initializer
- Initializer: The initializer takes two parameters: the initial wrappedValue and the range. The initializer ensures that the initial value is clamped within the range by using min and max functions:
self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
Example Usage:
struct Example {
@Clamped(0...10) var number: Int
init(number: Int) {
self._number = Clamped(wrappedValue: number, 0...10)
}
}
struct ClampExample {
func clampA() {
var example = Example(number: 15)
print(example.number) // 10, because 15 is clamped to the upper bound 10
example.number = -5
print(example.number) // 0, because -5 is clamped to the lower bound 0
}
}
The Clamped property wrapper ensures that the number property is always within the range 0…10. If the value exceeds the bounds, it is automatically clamped.
Advanced Property Wrapper Use Cases
1. Custom Projected Values
Projected values allow you to expose additional functionality via the $ syntax. Let’s extend our Clamped wrapper to include a projected value indicating whether the clamped value is at its upper or lower bounds.
@propertyWrapper
struct Clamped<Value: Comparable> {
private var value: Value
private let range: ClosedRange<Value>
init(wrappedValue: Value, _ range: ClosedRange<Value>) {
self.range = range
self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
}
var wrappedValue: Value {
get { value }
set { value = min(max(newValue, range.lowerBound), range.upperBound) }
}
var projectedValue: (isAtLowerBound: Bool, isAtUpperBound: Bool) {
(value == range.lowerBound, value == range.upperBound)
}
}
This means that if the initial wrappedValue is less than the lower bound, it will be set to the lower bound. If it is greater than the upper bound, it will be set to the upper bound.
Computed Property
- Getter: The getter simply returns the stored value.
- Setter: The setter updates value and ensures that the new value is within the specified range using the same clamping logic as in the initializer:
value = min(max(newValue, range.lowerBound), range.upperBound)
Example Usage:
struct Example {
@Clamped(0...10) var number: Int = 0
}
struct ClampExample {
func clampA() {
var example = Example()
example.number = 15
print(example.$number.isAtUpperBound) // true
print(example.$number.isAtLowerBound) // false
}
}
In this example:
• The percentage property will always stay within the range of 0 to 100, no matter what value you try to assign to it.
Key Points
• Clamping Logic: The core of this property wrapper is the clamping logic, which ensures that values are kept within a specified range.
• Range Flexibility: The ClosedRange
• Reusability: This property wrapper is reusable across different types and contexts, making it a versatile tool in your Swift codebase.
2. Combining Multiple Property Wrappers
You can combine multiple property wrappers to apply multiple layers of logic to a single property. For example, let’s create a property that is both clamped and logs every change.
@propertyWrapper
struct Logged<Value> {
private var value: Value
private let label: String
init(wrappedValue: Value, label: String) {
self.value = wrappedValue
self.label = label
print("\(label): Initial value set to \(value)")
}
var wrappedValue: Value {
get { value }
set {
print("\(label): Value changed from \(value) to \(newValue)")
value = newValue
}
}
}
Key Points
• Property Wrapper: Logged is a Swift property wrapper that adds logging behavior to properties.
• Generic Structure: It is a generic structure (Logged
• Stored Value: The actual value is stored in a private property value, which is of type Value.
• Custom Label: A label string is used to identify the property in log messages, making it easier to distinguish between different logged properties.
• Initializer: The initializer takes the initial wrappedValue and a label, sets these values, and prints a message with the initial value.
• Getter and Setter:
• Getter: The wrappedValue getter returns the current value of the property.
• Setter: The wrappedValue setter logs the old and new values whenever the property is changed, and then updates the value.
• Usage: When applied to a property, the Logged wrapper automatically logs any changes to the property’s value, including its initial setting.
Example Usage:
struct LogProperty {
@Logged(label: "Example Number") @Clamped(0...10) var number = 5
}
struct LogExample {
func logExample() {
var example = LogProperty()
example.number = 15 // Logs the change and clamps the value to 10
}
}
The Logged wrapper logs the changes to the property, while the Clamped wrapper ensures that the value remains within the specified range.
3. Handling Asynchronous Operations
Property wrappers can manage asynchronous operations, such as fetching data from a remote source. Here’s an example of a RemoteValue property wrapper that lazily fetches a value asynchronously.
@propertyWrapper
struct RemoteValue<Value> {
private var value: Value?
private let fetchValue: () async -> Value
init(fetchValue: @escaping () async -> Value) {
self.fetchValue = fetchValue
}
var wrappedValue: Value? {
value
}
mutating func fetch() async -> Value {
if let value = value {
return value
} else {
let fetchedValue = await fetchValue()
self.value = fetchedValue
return fetchedValue
}
}
}
Key Points
• Property Wrapper: RemoteValue is a Swift property wrapper that is designed to asynchronously fetch and cache a value of a specified type (Value).
• Generic Structure: emoteValue is a generic structure, meaning it can be used with any type Value.
• Private Variables:
- private var value: Value?: This stores the cached value. It is optional because the value may not be available immediately.
- private let fetchValue: () async -> Value: This is a closure that, when called, will asynchronously fetch the value. It returns a value of type Value.
• Initializer: The initializer takes the fetchValue closure as a parameter and stores it in the fetchValue property. The @escaping keyword is used because the closure will be stored and used later, beyond the scope of the initializer.
- wrappedValue Computed Property:var wrappedValue: Value?: This computed property provides access to the cached value. It returns the current value if it has been fetched and cached, or nil if the value has not yet been fetched.
• Asynchronous Fetch Method:
- mutating func fetch() async -> Value: This method performs the asynchronous fetching of the value.
- if let value = value: If the value has already been fetched and cached, it is returned immediately.
- else: If the value is not cached, the fetchValue closure is called asynchronously to fetch the value. The fetched value is then cached in self.value for future access, and the fetched value is returned.
• Caching Mechanism
- The wrapper caches the fetched value after the first fetch, so subsequent accesses to the wrappedValue or calls to fetch() return the cached value without needing to fetch it again.
• Usage Scenario
- This property wrapper is useful when you have a value that is expensive or time-consuming to fetch, such as data from a remote server, and you want to cache it to avoid multiple expensive fetches.
Example Usage:
struct RemoteExample {
@RemoteValue(fetchValue: {
// Simulate network delay
try? await Task.sleep(nanoseconds: 2 * 1_000_000_000)
return "Fetched Data"
}) var data: String?
mutating func loadData() async {
let data = await _data.fetch()
print(data) // Prints "Fetched Data" after 2 seconds
}
}
In this example:
• The @RemoteValue wrapper is applied to the user property.
• The fetchValue closure asynchronously fetches the user data from a server.
• The loadUser() method triggers the fetch, and the fetched data is cached for future use.
This structure is particularly helpful in scenarios where you want to delay fetching data until it’s needed and ensure that the data is only fetched once.
4. Thread-Safe Properties
You can enforce thread safety for properties accessed from multiple threads by using property wrappers. Here’s a ThreadSafe wrapper that synchronizes access to a property.
@propertyWrapper
class ThreadSafe<Value> {
private var value: Value
private let queue = DispatchQueue(label: UUID().uuidString)
init(wrappedValue: Value) {
self.value = wrappedValue
}
var wrappedValue: Value {
get {
return queue.sync { value }
}
set {
queue.sync { value = newValue }
}
}
}
Key Points
• Purpose of the ThreadSafe Property Wrapper:
- Ensures that read and write operations on a property are thread-safe, which is crucial when multiple threads might access or modify the property at the same time.
• Private Storage and Dispatch Queue:
- value is a private property that stores the actual value that needs to be thread-safe.
- A private DispatchQueue is created with a unique label using UUID().uuidString to serialize operations, preventing race conditions.
• Initializer:
- Accepts an initial wrappedValue and assigns it to the value property, which the property wrapper will manage.
• Wrapped Value Getter:
- Uses queue.sync to safely read the value, ensuring the operation is thread-safe by preventing other threads from modifying the value during the read.
• Wrapped Value Setter:
- Uses queue.sync to safely write the value, serializing the operation so only one thread can modify the value at a time, avoiding race conditions.
Example Usage:
struct ThreadSafeProperty {
@ThreadSafe var count: Int = 0
}
struct ThreadSafeExample {
func example() {
let example = ThreadSafeProperty()
DispatchQueue.concurrentPerform(iterations: 1000) { _ in
example.count += 1
}
print(example.count) // 1000
}
}
• Usage in a Struct:
- The ThreadSafe property wrapper is applied to the count property in the Example struct, making access and modification of count thread-safe.
• Concurrent Access Example:
- DispatchQueue.concurrentPerform is used to simulate 1000 concurrent accesses to the count property.
- The property wrapper ensures the final value is correct (1000), as all accesses are synchronized.
• Final Output:
- The final print statement outputs 1000, demonstrating that the ThreadSafe property wrapper effectively prevents race conditions, ensuring the expected result.
5. Lazy Initialization with Dependencies
You can use property wrappers to lazily initialize a property that depends on other properties. Using lazy intializers is a powerful utility for deferred initialization, especially when the initialization of a value is expensive or when it depends on some other components that may not be ready at the point of property initialization.
@propertyWrapper
struct LazyDependent<Value> {
private var value: Value?
private let initializer: () -> Value
init(wrappedValue: @autoclosure @escaping () -> Value) {
self.initializer = wrappedValue
}
var wrappedValue: Value {
mutating get {
if let value = value {
return value
} else {
let newValue = initializer()
self.value = newValue
return newValue
}
}
mutating set {
value = newValue
}
}
mutating func reset() {
value = nil
}
}
Key Points
• Value Type:
- Value: This is a generic type placeholder for the value being wrapped.
• Stored Properties:
- value: An optional that stores the actual value once it’s initialized.
- initializer: A closure that will be used to lazily initialize the value.
• Initializer:
- init(wrappedValue:): This initializer takes a closure (@autoclosure @escaping () -> Value) that defines how the value should be initialized. The @autoclosure attribute allows you to pass an expression that will be lazily evaluated.
• Wrapped Value Property:
- The wrappedValue computed property handles the actual logic for deferred initialization.
- • Getter: If the value is already initialized, it returns it; otherwise, it initializes it using the initializer, stores the result in value, and then returns it.
- • Setter: It simply assigns a new value to value.
• Reset Method:
- reset(): This method allows the user to reset the value, effectively marking it as uninitialized again. The next time wrappedValue is accessed, it will be reinitialized using the initializer.
Usage Example:
struct LazyProperty {
var baseValue: Int
@LazyDependent var dependentValue: Int
init(baseValue: Int) {
self.baseValue = baseValue
self._dependentValue = LazyDependent(wrappedValue: baseValue * 2)
}
mutating func resetDependentValue() {
_dependentValue.reset()
}
}
struct LazyPropertyExample {
func example() {
var example = LazyProperty(baseValue: 10)
print(example.dependentValue) // 20
example.baseValue = 20
example.resetDependentValue() // Reset using the public method
print(example.dependentValue) // 40 (after reset)
}
}
• Lazy Initialization: The value is only computed when accessed for the first time.
• Reset Functionality: Allows you to manually reset the state, forcing reinitialization on the next access.
• Thread Safety: This implementation is not thread-safe. If you need it to be thread-safe, additional synchronization mechanisms would be required.
6. Validating Input with Custom Errors
Property wrappers can enforce validation rules and throw custom errors when the rules are violated.
@propertyWrapper
struct ValidatedEmail {
private var value: String
init(wrappedValue: String) {
if ValidatedEmail.isValidEmail(wrappedValue) {
self.value = wrappedValue
} else {
print("Invalid email provided during initialization. Setting default value.")
self.value = "invalid@example.com" // Default value in case of invalid email
}
}
var wrappedValue: String {
get { value }
set {
if ValidatedEmail.isValidEmail(newValue) {
value = newValue
} else {
print("Invalid email address")
}
}
}
static func isValidEmail(_ email: String) -> Bool {
let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
return NSPredicate(format: "SELF MATCHES %@", emailRegex).evaluate(with: email)
}
}
Key Points
• Initialization:
- When you initialize an instance with a string, the initializer checks if the string is a valid email address using the isValidEmail function.
- If valid, it assigns the value; if not, it sets a default value of “invalid@example.com”.
• Getter and Setter:
- The wrappedValue property provides access to the underlying value.
- The setter checks if the new value is a valid email. If valid, it updates the value; if not, it prints an error message and does not change the value.
• Validation Logic:
- The isValidEmail method uses a regular expression to validate the format of the email address.
Usage Example:
struct User {
@ValidatedEmail var email: String
init(email: String) {
self._email = ValidatedEmail(wrappedValue: email)
}
}
struct ValidExample {
func checkEmail() {
// Example of usage
var user = User(email: "example@domain.com")
print("User email: \(user.email)") // Output: User email: example@domain.com
// Attempting to update with a valid email
user.email = "new.email@domain.com"
print("Updated email: \(user.email)") // Output: Updated email: new.email@domain.com
// Attempting to update with an invalid email
user.email = "invalid-email"
print("After invalid update, email: \(user.email)") // Output: Invalid email address
}
}
Conclusion
Property wrappers allow developers to encapsulate common logic, making properties more expressive and easier to manage. We’ve seen how to create custom property wrappers, combine multiple wrappers, and handle various scenarios like validation, asynchronous operations, and thread safety.
By mastering property wrappers, you can significantly enhance the clarity and maintainability of your Swift code. Whether you’re simplifying input validation, enforcing business rules, or ensuring thread safety, property wrappers offer a flexible and reusable way to handle property-related logic.
As you continue to explore and implement property wrappers in your projects, you’ll discover even more ways to leverage this feature to create cleaner, more robust code.
If you want to learn more about native mobile development, you can check out the other articles I have written here: https://medium.com/@wesleymatlock
🚀 Happy coding! 🚀
By Wesley Matlock on August 19, 2024.
Exported from Medium on May 10, 2025.