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 Encodable
type 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 Data
and 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 Marisi, Daniel Haight and Neil Horton for reviewing this article.