Home > Enterprise >  Swift Codable: subclass JSONDecoder for custom behavior
Swift Codable: subclass JSONDecoder for custom behavior

Time:03-01

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:

  1. Writing Person.init(from:) manually by trying to decode both Int.self and String.self for the .age property and keeping whichever one succeeds, or

  2. If 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
    }
    
  3. You can also write StringOrNumber as an enum which can hold either case string(String) or case 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)

  • Related