-1. I’m still of the opinion that if Swift is going to embrace a single default for encoding enums a ”case as value” approach is a better default than a ”case as key” approach. The changes to the proposal since the first review don’t really change that. (And some great arguments have been made both in this thread and the previous thread that we in fact shouldn’t embrace a single default approach, but rather make it something that the user must choose explicitly.)
However, since the core team has specifically asked for input on the updated proposal from a schema evolution standpoint, I want to do a deep dive on that. Regardless of what approach we adopt, I think the out-of-the-box behavior should support changing schemas in backward/forward compatible ways as much as possible. If you slap Codable
on an enum, you should get something that you can use immediately, but it should also be something that you can change when you have data or clients out there using it. And the proposed format is not it.
It’s a common principle when evolving schemas that adding a member with a default value is a backwards compatible change. In the proposed format, this isn’t generally true for associated values.
Let’s look at some scenarios in detail:
A. Adding an unlabeled value to a case without associated values
Not backwards compatible. The container doesn't change, but the value changes from a Bool
to whatever type the associated value is. Data encoded with the old version will (probably?) throw if decoded with the new version.
|
Swift |
JSON |
Old |
enum E {
case test
}
|
{
"test": true
}
|
New |
enum E {
case test(Double = 0.0)
}
|
{
"test": 2.0
}
|
B. Adding an unlabeled value to a case with a single unlabeled value
Not backwards compatible. The container changes from a single value container to an unkeyed container, so data encoded with the old version will throw if decoded with the new version.
|
Swift |
JSON |
Old |
enum E {
case test(Int)
}
|
{
"test": 1.0
}
|
New |
enum E {
case test(Int, Double = 0.0)
}
|
{
"test": [1.0, 2.0]
}
|
C. Adding an unlabeled value to a case with multiple unlabeled values
Maybe backwards compatible. It depends on whether the synthesized init(from:)
handles unkeyed containers with fewer elements than expected when some or all associated values have default values. The behavior here is unspecified in the proposal, so it’s hard to tell.
|
Swift |
JSON |
Old |
enum E {
case test(Int, Int)
}
|
{
"test": [1.0, 1.0]
}
|
New |
enum E {
case test(Int, Int, Double = 0.0)
}
|
{
"test": [1.0, 1.0, 2.0]
}
|
D. Adding an unlabeled value to a case with labeled values
Not backwards compatible. The container changes from a keyed container to an unkeyed container, so data encoded with the old version will throw if decoded with the new version.
|
Swift |
JSON |
Old |
enum E {
case test(name: String)
}
|
{
"test": {
"name": "abc"
}
}
|
New |
enum E {
case test(name: String, Double = 0.0)
}
|
{
"test": ["abc", 2.0]
}
|
E. Adding a labeled value to a case without associated values
Not backwards compatible. The container changes from a single value container to an keyed container, so data encoded with the old version will throw if decoded with the new version.
|
Swift |
JSON |
Old |
enum E {
case test
}
|
{
"test": true
}
|
New |
enum E {
case test(name: String = "")
}
|
{
"test": {
"name": "abc"
}
}
|
F. Adding a labeled value to a case with a single unlabeled value
Not backwards compatible. The container changes from a single value container to an unkeyed container, so data encoded with the old version will throw if decoded with the new version.
|
Swift |
JSON |
Old |
enum E {
case test(Int)
}
|
{
"test": 1.0
}
|
New |
enum E {
case test(Int, name: String = "")
}
|
{
"test": [1.0, "abc"]
}
|
G. Adding a labeled value to a case with multiple unlabeled values
Maybe backwards compatible. It depends on whether the synthesized init(from:)
handles unkeyed containers with fewer elements than expected when some or all associated values have default values. The behavior here is unspecified in the proposal, so it’s hard to tell.
|
Swift |
JSON |
Old |
enum E {
case test(Int, Int)
}
|
{
"test": [1.0, 1.0]
}
|
New |
enum E {
case test(Int, Int, name: String = "")
}
|
{
"test": [1.0, 1.0, "abc"]
}
|
H. Adding a labeled value to a case with labeled values
Backwards compatible.
|
Swift |
JSON |
Old |
enum E {
case test(name: String)
}
|
{
"test": {
"name": "abc"
}
}
|
New |
enum E {
case test(name: String, category: String = "")
}
|
{
"test": {
"name": "abc",
"category": "things"
}
}
|
These scenarios highlight a couple of things:
-
Changing the type of container based on the payload is a really bad idea from a compatibility standpoint. The changes in the scenarios above would all be backward compatible if the synthesis always used a keyed container, and used "$0"
or "_0"
as keys for the unlabeled values, as has been suggested multiple times.
-
There is no underlying principle or general rule for which changes you can make to enums and maintain backward compatibility with the proposed format. In the previous version, you could at least go by ”adding a labeled value is always backward compatible”, but now that cases without associated values are encoded as true
, even that doesn’t work.
In fact, if you’re working on something where backwards compatibility is an absolute must, the only sound advice seems to be ”if an enum has associated values, always implement Codable
manually.” I can definitely see that becoming a lint rule in some projects.
-
The proposal still doesn’t specify how the synthesis will actually work in enough detail (e.g. how does decoding of unlabeled values with default values work?).