I'm having an inconsistent API that might return either a String
or an Number
as a part of the JSON response.
The dates also could be represented the same way as either a String
or a Number
, but are always an UNIX timestamp (i.e. timeIntervalSince1970
).
To fix the issue with the dates, I simply used a custom JSONDecoder.DateDecodingStrategy
:
decoder.dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.custom({ decoder in
let container = try decoder.singleValueContainer()
if let doubleValue = try? container.decode(Double.self) {
return Date(timeIntervalSince1970: doubleValue)
} else if let stringValue = try? container.decode(String.self),
let doubleValue = Double(stringValue) {
return Date(timeIntervalSince1970: doubleValue)
}
throw DecodingError.dataCorruptedError(in: container,
debugDescription: "Unable to decode value of type `Date`")
})
However, no such customization is available for the Int
or Double
types which I'd like to apply it for.
So, I have to resort to writing Codable
initializers for each of the model types that I'm using.
The alternative approach I'm looking for is to subclass the JSONDecoder
and override the decode<T>(_ type: T.Type, from data: Data) throws -> T where T : Decodable
method.
In that method I'd like to "inspect" the type T
that I'm trying to decode to and then, if the base implementation (super
) fails, try to decode the value first to String
and then to the T
(the target type).
So far, my initial prototype looks like this:
final class CustomDecoder: JSONDecoder {
override func decode<T>(_ type: T.Type, from data: Data) throws -> T where T : Decodable {
do {
return try super.decode(type, from: data)
} catch {
if type is Int.Type {
print("Trying to decode as a String")
if let decoded = try? super.decode(String.self, from: data),
let converted = Int(decoded) {
return converted as! T
}
}
throw error
}
}
}
However, I found out that the "Trying to decode as a String"
message is never printed for some reason, even though the control reaches the catch
stage.
I'm happy to have that custom path only for Int
and Double
types, since the T
is Codable
and that doesn't guarantee ability to initialize a value with the String
, however, I of course welcome a more generalized approach.
Here's the sample Playground code that I came up with to test my prototype. It can be copy-pasted directly into the Playground and works just fine.
My goal is to have both jsonsample1
and jsonsample2
to produce the same result.
import UIKit
final class CustomDecoder: JSONDecoder {
override func decode<T>(_ type: T.Type, from data: Data) throws -> T where T : Decodable {
do {
return try super.decode(type, from: data)
} catch {
if type is Int.Type {
print("Trying to decode as a String")
if let decoded = try? super.decode(String.self, from: data),
let converted = Int(decoded) {
return converted as! T
}
}
throw error
}
}
}
let jsonSample1 =
"""
{
"name": "Paul",
"age": "38"
}
"""
let jsonSample2 =
"""
{
"name": "Paul",
"age": 38
}
"""
let data1 = jsonSample1.data(using: .utf8)!
let data2 = jsonSample2.data(using: .utf8)!
struct Person: Codable {
let name: String?
let age: Int?
}
let decoder = CustomDecoder()
let person1 = try? decoder.decode(Person.self, from: data1)
let person2 = try? decoder.decode(Person.self, from: data2)
print(person1 as Any)
print(person2 as Any)
What could be the reason for my CustomDecoder
not working?
CodePudding user response:
The primary reason that your decoder doesn't do what you expect is that you're not overriding the method that you want to be: JSONDecoder.decode<T>(_:from:)
is the top-level method that is called when you call
try JSONDecoder().decode(Person.self, from: data)
but this is not the method that is called internally during decoding. Given the JSON you show as an example, if we write a Person
struct as
struct Person: Decodable {
let name: String
let age: Int
}
then the compiler will write an init(from:)
method which looks like this:
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
age = try container.decode(Int.self, forKey: .age)
}
Note that when we decode age
, we are not calling a method on the decoder directly, but on a KeyedCodingContainer
that we get from the decoder — specifically, the Int.Type
overload of KeyedDecodingContainer.decode(_:forKey:)
.
In order to hook into the methods that are called during decode at the middle levels of a Decoder
, you'd need to hook into its actual container methods, which is very difficult — all of JSONDecoder
's containers and internals are private. In order to do this by subclassing JSONDecoder
, you'd end up needing to pretty much reimplement the whole thing from scratch, which is significantly more complicated than what you're trying to do.
As suggested in a comment, you're likely better off either:
Writing
Person.init(from:)
manually by trying to decode bothInt.self
andString.self
for the.age
property and keeping whichever one succeeds, orIf you need to reuse this solution across many types, you can write a wrapper type to use as a property:
struct StringOrNumber: Decodable { let number: Double init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() do { number = try container.decode(Double.self) } catch (DecodingError.typeMismatch) { let string = try container.decode(String.self) if let n = Double(string) { number = n } else { throw DecodingError.dataCorruptedError(in: container, debugDescription: "Value wasn't a number or a string...") } } } } struct Person: Decodable { let name: String let age: StringOrNumber }
You can also write
StringOrNumber
as anenum
which can hold eithercase string(String)
orcase number(Double)
if knowing which type of value was in the payload was important:enum StringOrNumber: Decodable { case number(Double) case string(String) init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() do { self = try .number(container.decode(Double.self)) } catch (DecodingError.typeMismatch) { let string = try container.decode(String.self) if let n = Double(string) { self = .string(string) } else { throw DecodingError.dataCorruptedError(in: container, debugDescription: "Value wasn't a number or a string...") } } } }
Though this isn't as relevant if you always need
Double
/Int
access to the data, since you'd need to re-convert at the use site every time (and you call this out in a comment)