Friday, 20 July 2018

Workaround for serializing Codable fragments

TL;DR - Wrap the Codable type in an array and use a JSONDecoder to convert it to Data

Serialization of data has been immensely improved by the introduction of Codable. This interface provides an api with much less boilerplate than the classic NSCoding.
Currently Swift provides decoders and encoders for 2 types of data, JSON and property list:
One of the limitations is that neither of them support single values, also known as fragments. For example:
let json = "A string".data(using: .utf8)!
do {
    try JSONDecoder().decode(String.self, from: json)
} catch {
    // Error Domain=NSCocoaErrorDomain Code=3840 "JSON text did not start
    // with array or object and option to allow fragments not set."
    // UserInfo={NSDebugDescription=JSON text did not start with array
    // or object and option to allow fragments not set.}
}
The issue related to JSON is raised on the Swift JIRA, SR-6163. The reason this happens is because JSONDecoder is implemented using JSONSerialization but it doesn't provide an option to allow fragments which is where the error comes from.

Real life example

A popular tool for persistency in Swift is Disk. One of it's features is the ability to save a Codable type to disk:
public extension Disk {
    static func save<T: Encodable>(_ value: T, to directory: Directory, as path: String) throws {
        // Implementation using JSONEncoder
    }
}
The library is great but it's limited by the Swift issue, if we try to save a string it'll fail:
try Disk.save("A string", to: .documents, as: "filename.extension") // fails
No compiler errors are given because String is Encodable but this will throw the following error:
Error Domain=NSCocoaErrorDomain Code=4866 "Top-level String encoded as string JSON fragment." UserInfo={NSCodingPath=( ), NSDebugDescription=Top-level String encoded as string JSON fragment.}
Both the API and the documentation (Disk currently supports persistence of the following types: Codable, ...) suggest this should work. However, since Disk is implemented using JSONEncoder for serialization it doesn't.
Note that I used Disk as an example because it's well documented, has good tests and works very nicely. The fact that I'm pointing a bug doesn't mean it's anything other than great.

One possible (and temporary) solution to this problem

Since Swift does not support fragments a reliable and somewhat questionable implementation is to wrap the Encodabletype into an array to guarantee it'll encode:
private extension Encodable {
    func encode() -> Data? {
        return try? JSONEncoder().encode([self])
    }
}
This serialized Data can then be stored into the disk or sent somewhere.
To retrieve the original Codable type we can revert the process. We decode the data, take the first item in the array and cast it to the corresponding type. Note that this returns a generic parameter because there is no easy way to encode the original type.
extension Data {
    func decode<T: Decodable>() -> T? {
        return (try? JSONDecoder().decode([T].self, from: self))?.first
    }
}
Going back to the original example from SR-6163, these extensions can be used like this:
let jsonPrimitive = "A string"
let encodedData = jsonPrimitive.encode()
let decodedValue: String? = encodedData?.decode() // "A string"

Conclusions

The proposed workaround is by no means elegant or space efficient but it reliably converts any Codable type to Dataand back to Codable. Therefore, this is good tool to have in your toolbelt until swift provides a decoder/encoder that allows fragments as Codable provides a very convenient way of serializing objects.

I’d like to thank Nahuel MarisiDaniel Haight and Neil Horton for reviewing this article.

0 comments:

Post a Comment