swiftcodable

JSONEncoder won't allow type encoded to primitive value


I'm working on an implementation of Codable for an enum type with possible associated values. Since these are unique to each case, I thought I could get away with outputting them without keys during encoding, and then simply see what I can get back when decoding in order to restore the correct case.

Here's a very much trimmed down, contrived example demonstrating a sort of dynamically typed value:

enum MyValueError : Error { case invalidEncoding }

enum MyValue {
    case bool(Bool)
    case float(Float)
    case integer(Int)
    case string(String)
}

extension MyValue : Codable {
    init(from theDecoder:Decoder) throws {
        let theEncodedValue = try theDecoder.singleValueContainer()

        if let theValue = try? theEncodedValue.decode(Bool.self) {
            self = .bool(theValue)
        } else if let theValue = try? theEncodedValue.decode(Float.self) {
            self = .float(theValue)
        } else if let theValue = try? theEncodedValue.decode(Int.self) {
            self = .integer(theValue)
        } else if let theValue = try? theEncodedValue.decode(String.self) {
            self = .string(theValue)
        } else { throw MyValueError.invalidEncoding }
    }

    func encode(to theEncoder:Encoder) throws {
        var theEncodedValue = theEncoder.singleValueContainer()
        switch self {
        case .bool(let theValue):
            try theEncodedValue.encode(theValue)
        case .float(let theValue):
            try theEncodedValue.encode(theValue)
        case .integer(let theValue):
            try theEncodedValue.encode(theValue)
        case .string(let theValue):
            try theEncodedValue.encode(theValue)
        }
    }
}

let theEncodedValue = try! JSONEncoder().encode(MyValue.integer(123456))
let theEncodedString = String(data: theEncodedValue, encoding: .utf8)
let theDecodedValue = try! JSONDecoder().decode(MyValue.self, from: theEncodedValue)

However, this is giving me an error during the encoding stage as follows:

 "Top-level MyValue encoded as number JSON fragment."

The issue appears to be that, for whatever reason, the JSONEncoder won't allow a top-level type that isn't a recognised primitive to be encoded as a single primitive value. If I change the singleValueContainer() to an unkeyedContainer() then it works just fine, except that of course the resulting JSON is an array, not a single value, or I can use a keyed container but this produces an object with the added overhead of a key.

Is what I'm trying to do here impossible with a single value container? If not, is there some workaround that I can use instead?

My aim was to make my type Codable with a minimum of overhead, and not just as JSON (the solution should support any valid Encoder/Decoder).


Solution

  • There is a bug report for this:

    https://bugs.swift.org/browse/SR-6163

    SR-6163: JSONDecoder cannot decode RFC 7159 JSON

    Basically, since RFC-7159, a value like 123 is valid JSON, but JSONDecoder won't support it. You may follow up on the bug report to see any future fixes on this. [The bug was fixed starting in iOS 13.]

    #Where it fails#

    It fails in the following line of code, where you can see that if the object is not an array nor dictionary, it will fail:

    https://github.com/apple/swift-corelibs-foundation/blob/master/Foundation/JSONSerialization.swift#L120

    open class JSONSerialization : NSObject {
            //...
    
            // top level object must be an Swift.Array or Swift.Dictionary
            guard obj is [Any?] || obj is [String: Any?] else {
                return false
            }
    
            //...
    } 
    

    #Workaround#

    You may use JSONSerialization, with the option: .allowFragments:

    let jsonText = "123"
    let data = Data(jsonText.utf8)
    
    do {
        let myString = try JSONSerialization.jsonObject(with: data, options: .allowFragments)
        print(myString)
    }
    catch {
        print(error)
    }
    

    Encoding to key-value pairs

    Finally, you could also have your JSON objects look like this:

    { "integer": 123456 }

    or

    { "string": "potatoe" }

    For this, you would need to do something like this:

    import Foundation 
    
    enum MyValue {
        case integer(Int)
        case string(String)
    }
    
    extension MyValue: Codable {
        
        enum CodingError: Error { 
            case decoding(String) 
        }
        
        enum CodableKeys: String, CodingKey { 
            case integer
            case string 
        }
    
        init(from decoder: Decoder) throws {
    
            let values = try decoder.container(keyedBy: CodableKeys.self)
    
            if let integer = try? values.decode(Int.self, forKey: .integer) {
                self = .integer(integer)
                return
            }
    
            if let string = try? values.decode(String.self, forKey: .string) {
                self = .string(string)
                return
            }
    
            throw CodingError.decoding("Decoding Failed")
        }
    
    
        func encode(to encoder: Encoder) throws {
            var container = encoder.container(keyedBy: CodableKeys.self)
    
            switch self {
                case let .integer(i):
                try container.encode(i, forKey: .integer)
                case let .string(s):
                try container.encode(s, forKey: .string)
            }
        }
    
    }
    
    let theEncodedValue = try! JSONEncoder().encode(MyValue.integer(123456))
    let theEncodedString = String(data: theEncodedValue, encoding: .utf8)
    print(theEncodedString!) // { "integer": 123456 }
    let theDecodedValue = try! JSONDecoder().decode(MyValue.self, from: theEncodedValue)