Unfortunately, with the current state of how Codable
synthesis works, it's not quite as simple as introducing new strategies to various encoders and decoders β we would also need to update how the compiler synthesizes the implementation of init(from:)
and encode(to:)
to take advantage of this somehow. Let's take a look at why. [I'll be focusing largely on the decoding side of things here, but the same applies to possible new encoding strategies.]
Taking your original example of
struct S: Decodable {
var x: Int
var y: Optional<Int>
}
we can expand the Decodable
conformance as the compiler would. To somewhat simplify the process, synthesizing Encodable
/Decodable
conformance does the following:
- Checks to see whether the type has a
CodingKeys
enum
- If so, validates the type to ensure each
CodingKeys
case maps to a property by name, and that all non-computed properties map to a case in the CodingKeys
enum
- If not, synthesizes a
CodingKeys
enum which satisfies the above, if possible
- Implements
init(from:)
/encode(to:)
by synthesizing a method which
- Creates a keyed container
- Iterates over each property in the type's
CodingKeys
enum, calling the matching encode(..., forKey:)
/decode(..., forKey:)
on the container for each key in the enum
-
Optional
types have decodeIfPresent
called for the type, rather than decode
, as mentioned above
When I say "synthesize" here, I mean that the compiler inserts new nodes into the AST which represents the code as parsed from your file β and there is no magic here. The insertions the compiler performs behave exactly as if you had typed the lines of code into your source file directly.
Expanding the above rules for S
, you get the equivalent of
struct S: Decodable {
var x: Int
var y: Optional<Int>
private enum CodingKeys: CodingKey {
case x
case y
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
x = try container.decode(Int.self, forKey: .x)
y = try container.decodeIfPresent(Int.self, forKey: .y)
}
}
This behaves exactly as if you had typed this into a source file yourself, with nothing special about it. I don't have the tools to compile the above (and dump the AST for comparison), but you should be able to confirm this (barring any typos or minor details I've missed above).
To the issue at hand: note that at no point in synthesis does the compiler take into consideration whether y
has a default value, nor for that matter, whether y
is declared as Int?
or Optional<Int>
. It could, because it has access to the source in your file, but it doesn't at the moment. This means that right now, the specific spelling of the type of y
is not present in any way in the implemented init(from:)
, nor is y
's default value. This is an issue for your suggested strategy idea, because it means that JSONDecoder
has no access to either that spelling, nor whether a default value exists β as is, at the moment, it would not be possible to implement this.
Could this be implemented? Absolutely. You can imagine new additions to the Decoder
protocol which offer an opportunity for the compiler to pass this information over to a Decoder
with something like:
protocol KeyedDecodingContainerProtocol {
func decode<T: Decodable>(_ type: T.self, forKey key: Key, defaultValue: T) throws -> T
func decodeIfPresent<T: Decodable>(_ type: T.self, forKey key: Key, defaultValue: T??, spelling: Spelling) throws -> T?
}
and in the case of S
or Foo
above, you'd see the compiler generate something like:
// S
x = try container.decode(Int.self, forKey: .x)
y = try container.decodeIfPresent(Int.self, forKey: y, defaultValue: .none, spelling: .desugaredOptional)
// Foo
x = try container.decode(Int.self, forKey: .x)
y = try container.decodeIfPresent(Int.self, forKey: .y, defaultValue: .some(nil), spelling: .sugaredOptional)
z = try container.decodeIfPresent(Int.self, forKey: .z, defaultValue: 123, spelling: .sugaredIUO)
w = try container.decodeIfPresent(Int.self, forKey: .w, defaultValue: 456, spelling: .sugaredOptional)
Then, any decoder which decodes a Foo
could decide what to do for each of those fields.
Some consequences of this approach:
- The container types and protocols need overloads for all of these types, with default implementations in terms of the older methods which throw the info away
- This muddies the water a bit for authors of custom
init(from:)
and encode(to:)
, because these methods are unlikely to provide much of a benefit for them (and I'll elaborate on this a bit below*) β this might significantly expand the API surface, but only really for the benefit of synthesized code
- This makes life a bit more difficult for
Encoder
/Decoder
authors, because they have a new set of methods they must implement, and if they forget an overload to one of the methods, the compiler cannot help them because it has a default implementation. (This leads to subtle broken behavior for clients)
- The compiler implementation of synthesis needs to be updated to support this, and existing source code needs to be recompiled in order to re-synthesize implementations
- If you're a consumer of a binary framework, you might be surprised to see existing types fail to respect these new options until you get a newly recompiled version of the framework (or vice versa β you may have to watch new versions of the framework to catch behavioral differences and account for them)
There are definitely other solutions out there which might work better, but about this one specifically: IMO, there's non-trivial risk in all of the potential corner cases; it also feels a bit strange and clunky that the compiler needs to "leak" source-specific information to Encoder
s and Decoder
s at runtime to implement this.
However: if we'd need to update the compiler to support this anyway, it's worth considering how far we could get if we just updated the compiler, without having to touch libraries at all. *Callout from above: if you already want the behavior of .dontOverrideFieldsThatHaveDefaultValues
for a type like Foo
, how might you write init(from:)
yourself?
One possibility is
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
x = try container.decode(Int.self, forKey: .x)
y = try container.decodeIfPresent(Int.self, forKey: .y)
// Don't overwrite `z` from its current default if no value is present.
if let _z = try container.decodeIfPresent(Int.self, forKey: .z) {
z = _z
}
// Don't overwrite `w` from its current default if no value is present.
if let _w = try container.decodeIfPresent(Int.self, forKey: .w) {
w = _w
}
}
Note that the above is entirely possible with existing language features in source, without needing any library updates. In other words, if someone were already writing init(from:)
themselves, the introduction of the decode*(..., defaultValue: T...)
methods suggested above (or similar) might not be beneficial.
This solution still isn't perfect, as:
- Changing this to become the new default behavior is extremely behavior-breaking without user intervention, which is a no-go
- Likely, you'd want some way to opt in to this on a type- or property-basis, but there's no current spelling for annotating a property in this way. There have been a lot of suggestions for how to do this over the years, but no solution has felt unanimously "right" (and significantly and clearly better than just writing your own
init(from:)
)
- You can't override this for a specific
Decoder
, unlike the other solution (a pro in some cases, and a con in others)
- Same binary framework concerns as above: without recompilation, code won't update to make use of this, and it may be problematic to mix-and-match code compiled with different compiler versions
To be clear, none of these problems are intractable, and I don't mean to imply as such (or that this isn't worth addressing in some way!) β with enough motivation, it's possible to come up with a clear story for all of this. But there are challenges which make this unfortunately not quite as easy as adding a strategy, and questions we'd need to answer before we can make this happen.