5

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).

Haravikk
  • 3,109
  • 1
  • 33
  • 46
  • 1
    There's no issue with the codable implementation in your type (though you should swap the float/integer cases when decoding or any integers are going to get caught in the float case), JSONEncoder/Decoder just doesn't support coding top-level objects that aren't an array/dictionary. If when you actually use this type you are using it as a property of another codable object then it will work fine. – dan May 09 '18 at 15:55
  • Related: https://stackoverflow.com/questions/46768535/swift-4-decode-simple-root-level-json-value – Martin R May 09 '18 at 16:19
  • 1
    A simple solution would be to wrap the value in an array, e.g. `let theEncodedValue = try! JSONEncoder().encode([MyValue.integer(123456)])` and then decode it with `let theDecodedValue = try! JSONDecoder().decode([MyValue].self, from: theEncodedValue)` – Guy Kogus May 09 '18 at 17:38
  • Actually, making that change shows that @dan was right and it does parse the number as a float, not an int. – Guy Kogus May 09 '18 at 17:39
  • See also https://stackoverflow.com/questions/59473051/userdefault-property-wrapper-not-saving-values-ios-versions-below-ios-13 – matt Sep 14 '21 at 10:18

1 Answers1

13

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)
matt
  • 515,959
  • 87
  • 875
  • 1,141
R.B.
  • 422
  • 4
  • 10
  • Ack, well that's a classic facepalm moment then; of course this type isn't intended as a top level element, so the problem is with my test, not the implementation. Thanks for the excellent answer! – Haravikk May 09 '18 at 17:51
  • Oh, as a note; I did try a key/value option and found a neat way of doing it. By pulling out `values.allKeys.first` you can actually use a switch to perform the correct decode immediately, rather than trying them all one at a time, especially handy with a lot of cases; I just didn't like the overhead of adding a key to the encoded format. – Haravikk May 09 '18 at 17:53
  • Awesome. Thanks for the side note! – R.B. May 09 '18 at 19:06