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

5
  • 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. Commented May 9, 2018 at 15:55
  • Related: stackoverflow.com/questions/46768535/… Commented May 9, 2018 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) Commented May 9, 2018 at 17:38
  • Actually, making that change shows that @dan was right and it does parse the number as a float, not an int. Commented May 9, 2018 at 17:39
  • See also stackoverflow.com/questions/59473051/… Commented Sep 14, 2021 at 10:18

2 Answers 2

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)
Sign up to request clarification or add additional context in comments.

3 Comments

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!
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.
Awesome. Thanks for the side note!
1

In addition to above answer which shows a dictionary or key-value container, there is an also a single value container when you don't want to have the integer and string in your json.

enum MyValue {
    case integer(Int)
    case string(String)

    public func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        switch self {
        case .string(let value):
            try container.encode(value)
        case .integer(let value):
            try container.encode(value)
    }
}

The above allows allows integers or strings

{ 
    "test1": 42,
    "test2": "hello"
}

Another option is if you want to have an array rather than a dictionary use unkeyedContainer().

enum MyValue {
    case integer(Int)
    case string(String)

    public func encode(to encoder: Encoder) throws {
        var container = encoder.unkeyedContainer()
        switch self {
        case .string(let value):
            try container.encode(value)
        case .integer(let value):
            try container.encode(value)
    }
}

with output like:

 { 
    "test1": [42],
    "test2": ["hello"]
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.