How to use Swift JSONDecode with dynamic types?
The Codable
API is built around encoding from and decoding into concrete types. However, the round-tripping you want here shouldn't have to know about any concrete types; it's merely concatenating heterogenous JSON values into a JSON object.
Therefore, JSONSerialization
is a better tool for the job in this case, as it deals with Any
:
import Foundation
// I would consider lifting your String keys into their own type btw.
func buildAnswer(keys: [String]) throws -> Data {
var result = [String: Any](minimumCapacity: keys.count)
for key in keys {
let data = CRUD.shared.restoreKey(key: key)
result[key] = try JSONSerialization.jsonObject(with: data)
}
return try JSONSerialization.data(withJSONObject: result)
}
That being said, you could still make this with JSONDecoder
/JSONEncoder
– however it requires quite a bit of type-erasing boilerplate.
For example, we need a wrapper type that conforms to Encodable
, as Encodable
doesn't conform to itself:
import Foundation
struct AnyCodable : Encodable {
private let _encode: (Encoder) throws -> Void
let base: Codable
let codableType: AnyCodableType
init<Base : Codable>(_ base: Base) {
self.base = base
self._encode = {
var container = $0.singleValueContainer()
try container.encode(base)
}
self.codableType = AnyCodableType(type(of: base))
}
func encode(to encoder: Encoder) throws {
try _encode(encoder)
}
}
We also need a wrapper to capture a concrete type that can be used for decoding:
struct AnyCodableType {
private let _decodeJSON: (JSONDecoder, Data) throws -> AnyCodable
// repeat for other decoders...
// (unfortunately I don't believe there's an easy way to make this generic)
//
let base: Codable.Type
init<Base : Codable>(_ base: Base.Type) {
self.base = base
self._decodeJSON = { decoder, data in
AnyCodable(try decoder.decode(base, from: data))
}
}
func decode(from decoder: JSONDecoder, data: Data) throws -> AnyCodable {
return try _decodeJSON(decoder, data)
}
}
We cannot simply pass a Decodable.Type
to JSONDecoder
's
func decode<T : Decodable>(_ type: T.Type, from data: Data) throws -> T
as when T
is a protocol type, the type:
parameter takes a .Protocol
metatype, not a .Type
metatype (see this Q&A for more info).
We can now define a type for our keys, with a modelType
property that returns an AnyCodableType
that we can use for decoding JSON:
enum ModelName : String {
case contactModel = "ContactModel"
case anythingModel = "AnythingModel"
var modelType: AnyCodableType {
switch self {
case .contactModel:
return AnyCodableType(ContactModel.self)
case .anythingModel:
return AnyCodableType(AnythingModel.self)
}
}
}
and then do something like this for the round-tripping:
func buildAnswer(keys: [ModelName]) throws -> Data {
let decoder = JSONDecoder()
let encoder = JSONEncoder()
var result = [String: AnyCodable](minimumCapacity: keys.count)
for key in keys {
let rawValue = key.rawValue
let data = CRUD.shared.restoreKey(key: rawValue)
result[rawValue] = try key.modelType.decode(from: decoder, data: data)
}
return try encoder.encode(result)
}
This probably could be designed better to work with Codable
rather than against it (perhaps a struct to represent the JSON object you send to the server, and use key paths to interact with the caching layer), but without knowing more about CRUD.shared
and how you use it; it's hard to say.
I would like to decode like this:
let result = try! decoder.decode(modelNameToType["ContactModel"]!, from: data)
But I get the error:
Cannot invoke 'decode' with an argument list of type (Codable.Type, from: Data)
You are using decode
incorrectly. The first parameter to decoder.decode
must not be an object; it must be a type. You cannot pass a metatype wrapped up in an expression.
You can, however, pass an object and take its type. So you could solve this with a generic that guarantees that we are a Decodable adopter. Here's a minimal example:
func testing<T:Decodable>(_ t:T, _ data:Data) {
let result = try! JSONDecoder().decode(type(of:t), from: data)
// ...
}
If you pass a ContactModel instance as the first parameter, that's legal.