Customise property coding with Property wrappers
Never implement custom coding again for only one property
Have you even been in a situation where one of your codable properties needed some special treatment but you didn't want to implement custom encoding / decoding because you had 10 other properties that were good as they were? I've got some good news for you, @propertyWrapper
is here to help you with just that.
While property wrappers were not created specifically for this application and they, for sure, have some downsides, they are an awesome way to save yourself from having to implement a lot of encoding / decoding logic just for one property.
What are property wrappers? In a nutshell, a property wrapper is a way to attach some additional logic to a property while providing a way for users to still interact with them as if they were just regular properties.
What are we going to build
In this post I'm going to walk you through a practical example that I actually had to implement not that long ago. The application I'm working on is required to send timestamps with a maximum of 3 decimal precision to a server, if there are more decimals, the server responds with an error.
The problem is that in older versions of iOS, when a double gets encoded in JSON, it is prone to rounding errors. This caused many failed requests because when I thought I was sending 1714461068.107 I was actually sending 1714461068.107000001. There can be multiple solutions to this problem but many of them deal with having the same value when decoding the JSON while what I needed was to make sure that I don't have more than 3 decimals.
The easiest solution for this problem was to send the data as string and while this would be possible by simply changing the property to String
it would introduce a number of other issues, like having to convert a lot between the two data types or introducing something like a computed property and the whole story just didn’t feel right.
Note that depending on what iOS version you are supporting, you may or may not need to worry about this problem, but the scope of this article is to present how a property wrapper can be used for any kind of special decoding rules
What are the rules
Now that we settled for what we try to achieve, let's see our specifications. We want that our property can:
- Have a double as type
- Get encoded as string
- Be decoded from both strings and double values handling edge cases like empty strings or invalid string values (like 'foo')
Let's build our property wrapper
First up, let's see our codable type:
struct Person: Codable {
let name: String
let birthdayTimestamp: Double?
}
As you can see we are storring the a person's birthday as a timestamp. In a real world situation you would use a date object but it will work for our example. Let's create a property wrapper
@propertyWrapper
struct CodableDouble {
let wrappedValue: Double?
}
As you can see creating the wrapper itself is really easy. All you need is a struct with a wrapped value and the @propertyWrapper annotation. Once we have this, we can annotate our model's property with the new wrapper
struct Person: Codable {
let name: String
@CodableDouble
var birthdayTimestamp: Double?
}
Note that property wrappers can only be applied to variables.
This is all we will need to do with our codable type. This shows how easy this will be to use, especially if we have multple models using double timestamps.
This however doesn't do much on its own yet. Let's start adding some of the magic
Let's make our wrapper Decodable
We start by creating an extension for our property wrapper:
extension CodableDouble: Codable {
public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer() // 1.
if container.decodeNil() { // 2.
wrappedValue = nil
} else if let string = try? container.decode(String.self) { // 3.
if string.isEmpty {
wrappedValue = nil
} else if let double = Double(string) {
wrappedValue = double
} else {
throw DecodingError.typeMismatch(Double.self, .init(codingPath: decoder.codingPath, debugDescription: "String representation contained invalid double"))
}
} else if let double = try? container.decode(Double.self) { // 4.
wrappedValue = double
} else { // 5.
wrappedValue = nil
}
}
}
As you can see we have added a decoding logic. Let's go through it step by step to see what is happening:
-
Every decoding is starting with getting a container to decode values from. In JSON terms, you can have 3 types of containers. Keyed, Unkeyed and Single value representing an Object, an Array and a single value respectively. In our case we need a single value container as we are trying to decode a single double
-
From this point we handle the different cases we described above First of all, we check if the value of the field is representing a nil value
-
After that we try to decode the value as if it was a string. By design, we treat an empty string as nil as well. If our string is not empty, we try to convert it to double and if it succeeds, we have our value. In other cases we have an invalid string so we throw an error. It's a good idea to use a relevant built in error so we are throwing a DecodingError
-
If the value can not be decoded as string, we try to decode it as a double directly
-
In this final else, we treat everything else as nil. If this was code for some library, you would want to throw a relevant error if you have an array or an object value but knowing our projects specifications, that is not a likely case anyways
… and Encodable
Our property wrapper is Deocdable
now! Great! But what about encoding? Actually that is the easy part. We know from our specifications that we always want to encode CodableDouble values as string, so our encoding is only 3 lines:
extension CodableDouble: Codable {
// ... our init is here
public func encode(to encoder: any Encoder) throws {
guard let wrappedValue else { return }
var container = encoder.singleValueContainer()
try container.encode("\(wrappedValue)")
}
}
With this added, our model is fully capable of encoding double values as string without a custom encoding method. Feel free to go ahead and play around with different JSON strings and models to see our property wrapper in action.
But there is one more thing
If you tried it, you may have noticed that our property wrapper works fairly good for all kinds of situations except when the key of the property is missing completely from the JSON. In this case a missing key error is thrown...
The reason behind may not be obvious at first glance but is actually pretty simple. Let's take an other look at our property:
@CodableDouble
var birthdayTimestamp: Double?
You may be aware that marking a property optional solves the missing key errors and our property is an optional double. So why do we still have issues? The answer lies in how a property wrapper works. Even though you can interact with birthdayTimestamp as if it was just a regular optional Double
, actually it is a CodableDouble
and it is not optional at all. Because of this, Swift doesn't know that the key for our property is allowed to be missing.
You may also be wondering why isn't it enough that we handled all cases gracefully in our decode initialiser. Well, because the error is not coming from our init, but one level above that, where the container of our value is being created.
So how to solve it?
Now that we know where the error is coming from, we just have to find out how to modify it. We know that our property is design to appear in a JSON object (not array) and if you remember my list of possible container types from above, this means we need to look at KeyedDecodingContainer
. Let's create an extension to allow our type to be missing:
extension KeyedDecodingContainer {
func decode(_ type: CodableDouble, forKey key: Key) throws -> CodableDouble {
try decodeIfPresent(type, forKey: key) ?? CodableDouble(wrappedValue: nil)
}
}
Decode if present will not throw if the key is missing, just return nil, so we have the option to handle a missing key however we want. In our case, by initializing the property with a nil wrapped value
Conclusion
I hope this article helped you understand how to make good use of property wrappers for better codable types.
Related articles
Here are some more articles that may interest you. Check them out!
The reentrancy problem
When a new feature is introduced in a programming language, we as developers are often quick to assume it will solve many of their challenges. The excitement to experiment with such features is natural, but it’s often accompanied by the realisation that every solution brings its own set of unique challenges. This was no different when actors were introduced.
Read moreDebugging memory leaks in Xcode
Ever got into a situation where you observed some strange behaviour with your app? Crashes, very bad performance or strange warnings in the console? Or simply you looked at the memory usage of your app and realised that it’s way too high for what your app should be doing? Yeah! Most likely you are dealing with a memory leak.
Read moreSimplify your code with @Entry
If you’ve ever tried to tap into the systems provided by SwiftUI, you are very likely to know, how quickly the boilerplate in your code can grow in size. The @Entry macro provides a solution to that
Read more