The Swift Codable
protocols have made serializing / deserializing model objects significantly easier and more robust. One catch that I have run into is that in order to make a type Decodable
it needs to be able to add init(from: Decoder)
to it. This is fine if you are working with a struct, but there are a couple situations where you just can’t make a class Decodable
:
- A class that isn’t yours (Nice long winded explanation on that here)
- Something like an
NSManagedObject
where you are not able to write initializers
In this post, I’ll explain an option for making decoding these types easier and then build off of this post about updating objects from a Decoder, which also introduces some of the pieces that we’ll be working with, so that is worth a look first.
DecodingFactory
To decode non-Decodable
types, we’re going to start by defining a DecodingFactory
protocol which is able to create some type of object from a decoder:
protocol DecodingFactory {
associatedtype Model
func create(from decoder: Decoder) throws -> Model
}
Here’s an example of a DecodingFactory
, one that creates CLLocation
s:
struct CLLocationFactory: DecodingFactory {
enum Key: String, CodingKey {
case latitude
case longitude
}
func create(from decoder: Decoder) throws -> CLLocation {
let container = try decoder.container(keyedBy: Key.self)
let latitude = try container.decode(Double.self, forKey: .latitude)
let longitude = try container.decode(Double.self, forKey: .longitude)
return CLLocation(latitude: latitude, longitude: longitude)
}
}
This looks fairly similar to a manually written Decodable
class, and should be pretty easy to follow.
To make this useful, we will need to add some conveniences: one for DecodingFormat
and one for KeyedDecodingContainer
.
DecodingFormat
DecodingFormat
is a protocol created in the DecoderUpdatable
blog post, which JSONDecoder
and PropertyListDecoder
are made to conform to.
We can add an extension to this protocol, so that DecodingFactory
s can be used just as easily as Decodable
types:
extension DecodingFormat {
func decode<Factory: DecodingFactory>(using factory: Factory, from data: Data) throws -> Factory.Model {
return try factory.create(from: decoder(for: data))
}
}
In practice, using the CLLocationFactory
it would look like this:
let location = try JSONDecoder().decode(using: CLLocationFactory(), from: data)
and as you’d expect, the compiler is able to infer that this returns a CLLocation
.
KeyedDecodingContainer
A KeyedDecodingContainer
is the type that you get when calling container(keyedBy:)
on a Decoder
. Adding a convenience to it will make it easier to decode nested objects using a DecodingFactory
:
extension KeyedDecodingContainer {
func decode<Factory: DecodingFactory>(using factory: Factory, forKey key: K, userInfo: [CodingUserInfoKey : Any] = [:]) throws -> Factory.Model {
let nestedDecoder = NestedDecoder(from: self, key: key, userInfo: userInfo)
return try factory.create(from: nestedDecoder)
}
func decodeIfPresent<Factory: DecodingFactory>(using factory: Factory, forKey key: Key, userInfo: [CodingUserInfoKey : Any] = [:]) throws -> Factory.Model? {
guard try contains(key) && !decodeNil(forKey: key) else { return nil }
return try decode(using: factory, forKey: key, userInfo: userInfo)
}
}
This uses a NestedDecoder
which is also from the DecoderUpdatable
post.
Now we are able to to use our factories to decode CLLocations on an object.
struct Place: Decodable {
let name: String
let location: CLLocation
enum Key: String, CodingKey {
case name
case location
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: Key.self)
name = try container.decode(String.self, forKey: .name)
location = try container.decode(using: CLLocationFactory(), forKey: .location)
}
}
Managed Objects
We can use DecodingFactory
to decode NSManagedObject
s as well. With the create(from:)
function being an instance method instead of a static method, we are able to pass along other necessary context to the factory as well.
In the case of NSManagedObject
, specifically an NSManagedObjectContext
.
For this example, we’ll need to add another protocol, ManagedDecodable
, for our NSManagedObject
s to conform to, which inherits from DecoderUpdatable
and also has an entityName
.
protocol ManagedDecodable: DecoderUpdatable {
static var entityName: String { get }
}
Here’s an example of an NSManagedObject
that also conforms to ManagedDecodable
:
class User: NSManagedObject, ManagedDecodable {
static var entityName: String = "User"
@NSManaged var id: Int
@NSManaged var name: String
enum Key: String, CodingKey {
case id
case name
}
func update(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: Key.self)
id = try container.decode(Int.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
}
}
Other than writing the decoding code in update(from:)
instead of an init(from:)
method, this again looks pretty similar to standard decoding.
We can now write a DecodingFactory
that works for decoding any ManagedDecodable
NSManagedObject
:
struct ManagedDecodingFactory<Entity: NSManagedObject & ManagedDecodable>: DecodingFactory {
let context: NSManagedObjectContext
func create(from decoder: Decoder) throws -> Entity {
var entity = NSEntityDescription.insertNewObject(forEntityName: Entity.entityName, into: context) as! Entity
try entity.update(from: decoder)
return entity
}
}
Now creating an NSManagedObject
from data is as simple as:
let factory = ManagedDecodingFactory<User>(context: myContext)
let user = try JSONDecoder().decode(using: factory, from: data)
Alternatives and Tradeoffs
DecodingFactory
makes it possible to decode types that you would otherwise not be able to. A downside of this is that you are left writing boilerplate Decodable
code.
An alternative option would be to write a Decodable
wrapper object that can then create the type you wanted, so in the case of CLLocation
:
class LocationWrapper: Decodable {
let latitude: Double
let longitude: Double
lazy var value: CLLocation = {
return CLLocation(latitude: latitude, longitude: longitude)
}()
}
Using a wrapper you don’t need to make another protocol or write boilerplate decoding code. However, working with these wrapper objects makes it harder to use the object after it is decoded.
For decoding a top-level object, you still need to pass in a class different than the one you actually want, and then also unwrapping it:
let location = try JSONDecoder().decode(LocationWrapper.self, from: data).value
As a nested object, in order to avoid boilerplate code, you have to keep the wrapper around indefinitely and unwrap it whenever you want the value:
struct Place: Decodable {
let name: String
let location: LocationWrapper
}
let place = try JSONDecoder().decode(Place.self, from: data)
doSomethingWithALocation(place.location.value)
Also, unlike DecodingFactory
there is no way of passing additional information for the decoding, making it significantly harder to decode something like an NSManagedObject
.
As a side benefit, DecodingFactory
isn’t restricted to being used with classes. While it is possible to make a struct you didn’t write conform to Decodable
, that may not always be a good idea.
You can run into collision issues where multiple extensions try to make a struct conform to Decodable
using different keys.
This is not an issue with DecodingFactory
as you can have multiple ones for the same model type and be explicit about when you want to use which factory. You can even have it call different initializers:
struct AnotherCLLocationFactory: DecodingFactory {
enum Key: String, CodingKey {
case latitude
case longitude
case altitude
case horizontalAccuracy
case verticalAccuracy
case timestamp
}
func create(from decoder: Decoder) throws -> CLLocation {
let container = try decoder.container(keyedBy: Key.self)
let latitude = try container.decode(Double.self, forKey: .latitude)
let longitude = try container.decode(Double.self, forKey: .longitude)
let altitude = try container.decode(Double.self, forKey: .altitude)
let horizontalAccuracy = try container.decode(Double.self, forKey: .horizontalAccuracy)
let verticalAccuracy = try container.decode(Double.self, forKey: .verticalAccuracy)
let timestamp = try container.decode(Date.self, forKey: .timestamp)
let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
return CLLocation(
coordinate: coordinate,
altitude: altitude,
horizontalAccuracy: horizontalAccuracy,
verticalAccuracy: verticalAccuracy,
timestamp: timestamp
)
}
}
let locationA = try JSONDecoder().decode(using: CLLocationFactory(), from: data)
let locationB = try JSONDecoder().decode(using: AnotherCLLocationFactory(), from: data)
conclusion
I believe having to write a little extra boilerplate code is preferable to having model objects that are awkward to work with, or not being able to be decoded at all.
Also, if this boilerplate code is overwhelming, you always have the option to do your own code gen on your model objects, instead of relying on Xcode.