Hey, I think this is a great initiative and wanted to give my perspective.
There are a few things that I feel are either impossible or very hacky with Codable. The examples here a specific to JSON, but this applies to other formats as well.
Example 1: Define a unknown fields
property
When implementing a client library, we usually define structs
that represent the responses we get from the server, eg.
{
"id": "1",
"name": "Tobias"
}
struct Person: Codable {
let id: String
let name: String
}
When the server starts sending a new version of the object schema, it might add a new field.
{
"id": "1",
"name": "Tobias",
"age": 35
}
All good, we can still deserialize this object into Person
because JSONDecoder
will just ignore the unknown field.
Now we might have a method,
func api.put(person: Person) { ... }
That takes a Person
struct converts it into JSON and sends it the server, which saves it as-is. Now, if we take the object we got from the server, and then just put
it back, we would remove the age
field. Of course, this assumes that there is no other validation, and null
is a valid value for the age
field. Details don't matter so much, you get the point. It would be great if we could preserve the unknown field. Something like:
struct Person: Codable {
let id: String
let name: String
let unknownFields: [String: AnyCodable] // would be flattened into the same container as `Person`
}
At the moment there is no good representation of AnyCodable
. There are some implementations, but I feel like these is more of an hack (with lots of casting) and make a lot of assumptions about how the different Encoder/Decoder
s are implemented.
This brings be to the second example:
Example 2: Ability to interact with the serialisation directly
In web apis there is the notion of PATCH
requests, that only send the changes you want to make on an object, see RFC 7386 JSON Merge Patch.
With the current state of JSONEncoder/JSONDecoder
from Foundation
there is no good way to implement this.
let person = api.get() // Person(id: "1", name: "Tobias", age: 35)
var modifiedPerson = person
modifiedPerson.age = nil
api.patch(JSONMergPatch.makePatch(from: person, to: modifiedPerson))
/*
PATCH /person/1 HTTP/1.1
Host: example.org
Content-Type: application/merge-patch+json
{
"age": null
}
*/
Today, in order to implement this makePatch(from:to:)
function, you would need to first encode
both person structs into JSON Data
. Then use JSONSerialization
to parse this back into a Any
JSON object. Then cast it to [String: Any]
and then recursively compare the dictionary entries, casting the Any
values into the NSObject
types used by JSONSerialization
in order to compare them. Then finally, encode the JSON object [String: Any]
of the patch document back into Data
and send it to the server. Here, it would be great if the standard library would ship with a pure swift implementation akin to XJSONEncoder/XJSONDecoder
from swift-extras-json. This library allows to convert any Codable
to and from a type-safe JSONValue
, which then can be used for example to compute (or apply) a JSON Merge Patch document in a nice, performant and type-safe manner.
Example 3: Full precision numbers in JSON
This example is actually obsolete, after posting I realised I was using the wrong type: This actually works correctly with Foundation JSONEncoder
/ JSONDecoder
, when using Decimal
.
Wrong example with `NSDecimalNumber`
This is a bit special for JSON, and maybe I am missing something: In JSON numbers are encoded as a string: eg
{
"amount": 99.99
"currency": "EUR"
}
Here 99.99
is not a Double
or Float
, in the binary representation it is just a String
. But when we want to convert this into a Swift struct
, I don't think there is a way to do it like this:
import Foundation
var json = Data("""
{
"amount": 99.99,
"currency": "EUR"
}
""".utf8)
struct Money: Decodable {
enum CodingKeys: String, CodingKey {
case amount
case currency
}
let amount: NSDecimalNumber
let currency: String
init(from decoder: Decoder) throws {
let conatiner = try decoder.container(keyedBy: CodingKeys.self)
self.amount = NSDecimalNumber(string: try conatiner.decode(String.self, forKey: .amount))
self.currency = try conatiner.decode(String.self, forKey: .currency)
}
}
let decoder = JSONDecoder()
let money = try decoder.decode(Money.self, from: json)
/*
▿ DecodingError
▿ typeMismatch : 2 elements
- .0 : Swift.String
▿ .1 : Context
▿ codingPath : 1 element
- 0 : CodingKeys(stringValue: "amount", intValue: nil)
- debugDescription : "Expected to decode String but found a number instead."
- underlyingError : nil
*/
Again, JSONValue
from swift-extras-json models numbers correctly as .number(String)
. But even here, there is no way to get to the "raw" representation of the value through the Decoder
interface.
For reference, in Java world with Lombok and Jackson, this works just fine and will retain the full precision of the BigDecimal
when encoding/decoding JSON:
@Value
class Person {
final BigDecimal amount;
final String currency;
}
This is how it works
import Foundation
var json = Data("""
{
"amount": 99.99,
"currency": "EUR"
}
""".utf8)
struct Money: Codable {
let amount: Decimal
let currency: String
}
let decoder = JSONDecoder()
let money = try decoder.decode(Money.self, from: json)
print(money)
// Money(amount: 99.99, currency: "EUR")
let encoder = JSONEncoder()
encoder.outputFormatting = [ .prettyPrinted ]
let encodedMoney = try encoder.encode(money)
print(String(decoding: encodedMoney, as: UTF8.self))
/*
{
"amount" : 99.99,
"currency" : "EUR"
}
*/
Summary
I feel wha all these examples have in common is, that the rather "rigid" interface of Codable makes it very difficult for framework developers to build solutions that can handle special cases nicely without having to reinvent the entire serialisation / marshalling infrastructure. This has been mentioned multiple times in the thread: it would be nice, if we could move the magic out into library code.