With Swift 5.1 and Xcode 11, many new features came to Swift. A lot of these were built in to help enable SwiftUI in Apple’s latest OS releases. While diving into SwiftUI is a great way to understand those features, there are some that we don’t need to go into SwiftUI to understand. One of these is property wrappers.
I like to explain property wrappers by a literal definition of the phrase: They wrap properties in functionality. While there are some built into various Apple frameworks (ie SwiftUI), it’s easy to create your own, and they can give you a lot of neat functionality.
How to create a property wrapper
To define a property wrapper, you need to have a custom type (I typically use struct
s) that you mark as a property wrapper with the @propertyWrapper
keyword.
@propertyWrapper
struct Wrapper {
}
By adding that attribute, my type now must conform to its requirements. To do so, I need to add a non-static property named wrappedValue
. The type of this property will depend on the kind of wrapper you are building. Let’s look at some practical examples and see what we can build.
Easily decoding an ISO 8601 Date
Since Swift 3, we’ve had the very convenient Encodable
and Decodable
protocols. The type alias Codable
is how we typically see it. Having our models conform to Codable
allows us to easily encode and decode our models to and from JSON for API communication.
The Date
type conforms to Codable
. But how does it encode/decode by default?
struct DateTest: Codable {
var date: Date = Date()
}
let dateTest = DateTest()
let data = try JSONEncoder().encode(dateTest)
let json = try JSONSerialization.jsonObject(with: data, options: [])
print(json)
// {
// date = "608081487.019236";
// }
Okay. At least Date
can encode automatically. I can live with that. But it looks like it’s turning a date into a number. What if we want to work with different standard, such as an ISO 8601 Date?
What is an ISO 8601 Date? It a popular date format presented as a string where a date like 10:43 pm on April 4, 2020 would appear like this:
2020-04-08T22:43:30+00:00
So can Date
decode this by default? Let’s find out!
let isodateJSON = """
{
"date": "2012-07-14T01:00:00+01:00"
}
"""
let isoDecodeTest = try JSONDecoder().decode(DateTest.self, from: isodateJSON.data(using: .utf8)!)
So using a multi-line statement in Swift, I’m going to try and decode an instance of our DateTest
struct with this JSON object. Will this work? If you saw how Date
encoded itself, then you won’t be surprised by the response we get from trying to decode this JSON.
ERROR: typeMismatch(Swift.Double,
Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "date", intValue: nil)],
debugDescription: "Expected to decode Double but found a string/data instead.",
underlyingError: nil))
While we have a valid date format, Date
is expecting a Double
. What can we do?
We could create a custom JSONDecoder
when decoding our type. But if we want to support ISO 8601 dates across various models, then we have to create custom decoders for all of them. That could end up being a lot of manual decoding work. OR… we could create a property wrapper to automatically decode the string coming from our JSON into a valid Date
.
We start by creating our property wrapper. Ultimately, we want to provide a Date
. So that will be our wrapped type.
@propertyWrapper
struct ISO8601Date {
var wrappedValue: Date
}
This satisfies the basic requirements of @propertyWrapper
. Now we have to add our own functionality. In our case, here is where we will build our custom encoding and decoding capabilities.
We start by declaring ISO8601Date
itself as conforming to Codable
. This will let us wrap properties in a Codable
-conforming model and not cause any errors about a model needing to manually provide such conformance.
Next, we create our custom Codable
conformance within our ISO8601Date
struct.
init(from decoder: Decoder) throws {
let value = try decoder.singleValueContainer()
let stringValue = try value.decode(String.self)
if let date = ISO8601DateFormatter().date(from: stringValue) {
wrappedValue = date
} else {
throw DecodingError.typeMismatch(Date.self, DecodingError.Context(codingPath: [],
debugDescription: "Failed to decode ISO Date. Invalid string format."))
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
let string = ISO8601DateFormatter().string(from: wrappedValue)
try container.encode(string)
}
Our initializer will try and decode a string. If this string can be used to decode a date in the ISO 8601 standard format, it will do so. Otherwise, it will throw an error. Meanwhile, our custom encode function takes the stored date and encodes it into a string using the ISO8601DateFormatter
.
Lastly, we add a simple init to allow the user to set a default value with our property wrapper.
init(wrappedValue: Date) {
self.wrappedValue = wrappedValue
}
Put all of this together and what do we get? If we look at our original DateTest
model, we can now prepend the @ISO8601Date
attribute to our date property and it will encode to and decode from an ISO 8601 string automatically.
struct DateTest: Codable {
@ISO8601Date var date: Date = Date()
}
Using UserDefaults for property storage
You may be using UserDefaults
in your app to store simple properties. Perhaps it’s a Bool
saying if a user has enabled or disabled some feature. Or perhaps you’re storing a String
of the last logged in user. You may have built convenience properties to read and write these values. In doing so, you may have ended up with a pattern like this:
class SettingManager {
static var userViewedWhatsNew: Bool {
get {
UserDefaults.standard.bool(forKey: "userViewedWhatsNew")
}
set {
UserDefaults.standard.set(newValue, forKey: "userViewedWhatsNew")
}
}
static var lastLoggedInUser: String? {
get {
UserDefaults.standard.string(forKey: "lastLoggedInUser")
}
set {
UserDefaults.standard.set(newValue, forKey: "lastLoggedInUser")
}
}
}
There’s nothing wrong with the above code (except maybe hard-coded key values). But as you add support for more and more properties, things get to be real tedious. So if we were to abstract this out, what pattern do we see?
For each property, we are both (a) getting the value from UserDefaults
and (b) setting the value to UserDefaults
using the same (c) key. With this pattern in mind, let’s approach our property wrapper.
@propertyWrapper
struct UserDefault<StoredType> {
public let key: String
var wrappedValue: StoredType {
get {
UserDefaults.standard.object(forKey: key) as! StoredType
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
Instead of using wrappedValue
to store a value we initialize, like in our first example, we have a computed getter and setter. It is performing our reading and writing with UserDefaults
. The key used for those transactions? We provide it in our property wrapper.
What data type does this work with? You’ll see that we are using a generic type (StoredType
). It will inherit the type from whatever you end up wrapping.
But wait! What if you wrap an optional type, like String?
. Won’t the getter crash if there is no object for key
? Nope! Because it’s coming back as an optional, or Optional.none
case (if you look at how Optionals work in Swift), which is a valid value for something of type String?
. But if we did try to read a String
that wasn’t there, then it would crash.
So how about we add a default value?
@propertyWrapper
struct UserDefault<StoredType> {
public let key: String
public let defaultValue: StoredType
var wrappedValue: StoredType {
get {
UserDefaults.standard.object(forKey: key) as? StoredType ?? defaultValue
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
Now we will attempt to return a value of type StoredType
from UserDefaults
but, in the event no such object exists, we will return our defaultValue
.
How can we use this in our SettingManager
above?
class SettingManager {
@UserDefault(key: "userViewedWhatsNew", defaultValue: false)
static var userViewedWhatsNew: Bool
@UserDefault(key: "lastLoggedInUser", defaultValue: nil)
static var lastLoggedInUser: String?
}
If we were to add support for a new property stored in UserDefaults
, it is super simple.
Now, notice how we are setting the key
and defaultValue
properties when declaring our property wrapper. This is making use of the synthesized initializer on our struct. You could make your own init
function and use it when declaring the property wrapper.
Summary
Property wrappers are super helpful. They can help you take repeated code and patterns away from your properties and move them into a reusable component. As we’ve seen here, you can make various Codable
edge cases easy to solve. They can also help you with code you find yourself repeating in things like a set
, didSet
, or other such case.
Like other Swift features, property wrappers are just another tool that are worth having in your developer tool box. I hope this post helped demystify them a little bit and make them more approachable for you.
You can find the code for this post in this Github Gist.
Leave a Reply