Synthesized CodingKeys not available in lots of case

Hi !

I am facing an little issue as I use Encodable / Decodable protocols :
The Synthesized CodingKeys is not available when

  1. Using the Decodable protocol only and implementing the init method (in a extension, or directly in the struct)
  2. Using the Codable protocol and implementing the init and encode method
struct DecodedStruct {
    var test: String
}

extension DecodedStruct: Decodable {
    init(from decoder: Decoder) throws {
        DecodedStruct.CodingKeys.self // error
    }
}

struct DecodedStruct2: Decodable {
    var test: String
    
    init(from decoder: Decoder) throws {
        DecodedStruct2.CodingKeys.self // error
    }
}

struct DecodedStruct3: Encodable {
    var test: String
}

extension DecodedStruct3: Decodable {
    init(from decoder: Decoder) throws {
        DecodedStruct3.CodingKeys.self // works (guess : synthesized thanks to Encodable)
    }
}

struct CodedStruct1: Codable {
    var test: String
}

extension CodedStruct1 {
    init(from decoder: Decoder) throws {
        CodedStruct1.CodingKeys.self // error
    }
    
    func encode(to: Encoder) throws {
        
    }
}

struct CodedStruct2: Codable {
    var test: String
}

extension CodedStruct2 {
    init(from decoder: Decoder) throws {
        CodedStruct2.CodingKeys.self // works since encode method is not implemented
    }
}

It seems the CodingKeys is synthesized only when we implement only one of the two Codable methods AND the other protocol is synthesized.

It seems to be bad to me :
If I want a struct to be Decodable, but not Encodable, and I want to write a custom init, I need to write all Keys, even if inferred ones would be good.
If I want a struct to be Decodable and Encodable, if I write a custom init, it most cases I would write the custom encode method to get the same data in and out my struct.

Any Thoughts on this ?

2 Likes

Hi @zarghol — the behavior you're seeing here is intended. A bit of rationale:

  • In order for the compiler to be able to synthesize init(from:) and encode(to:), it needs to be able to use a key type in order to create a keyed container on your behalf

  • This key type is not an explicit requirement of the protocols (i.e. an associated type) because a given type can choose to encode into an unkeyed or single-value container instead, obviating the need for a key type

  • As a convenience, if not provided, it creates one, and a convention of naming the type CodingKeys enables the compiler to coordinate with you about the intent of a generated init(from:) or encode(to:) — it's a customization point that lets you control what the compiler is doing in some sense, without needing to drop down into writing your own

  • However, if you do decide to write your own init(from:) or encode(to:), there is no requirement for your key type to be named CodingKeys:

    struct MyType : Codable {
        private enum TotallyNotMyCodingKeysIPromise : String, CodingKey { ... }
    
        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: TotallyNotMyCodingKeysIPromise.self)
            // ...
        }
    }
    

    In the case of MyType above, you wouldn't need the compiler to generate an enum CodingKeys, since the type already uses its own

It's this last use-case that is troublesome. When you override init(from:) or encode(to:), the compiler doesn't have a reasonably easy way of telling whether CodingKeys synthesis makes sense for your type because you could've defined a different key type for yourself to use.

It'd be reasonable to then ask the following:

  1. "Why not always generate a CodingKeys type and I'll use it only if I need it?" Besides being mildly wasteful at compile-time, the issue here is that CodingKeys synthesis can fail (e.g. if a property is not Codable). When it does fail, the compiler needs to produce output that indicates what went wrong — but: very often, in cases where you have specific fields which are not Codable, or you want to provide a property-to-CodingKey mapping that isn't 1-to-1, you'd be providing your own CodingKeys type and overriding these methods yourself:

    struct MyType : Codable {
        var name: String
        var ignoreMe: NotCodable
    
        // ignoreMe isn't encoded or decoded, so no need for a key for it.
        private enum Keys : String, CodingKey {
             case name
        }
    
        // These use Keys
        init(from decoder: Decoder) throws { ... }
        func encode(to encoder: Encoder) throws { ... }
    }
    

    In the above case, attempting to generate a CodingKeys enum would fail because ignoreMe isn't Codable. The type has worked around this by implementing custom behavior to take care of it, but if we blindly created a CodingKeys type alongside Keys, we'd be producing errors for a type the developer neither wanted, nor knew exists (nor has any control over). [Alternatively, if we always silenced these errors, if there was an issue in creating the CodingKeys type and the developer did try to use it, they'd have no idea why the code wouldn't compile.]

  2. "In that case, why not create a CodingKeys type only if I'm going to use it?" This is possible to do in a sense, but complicates the model of what happens here. This requires the compiler to look through the AST of the init(from:) and encode(to:) methods to find references to a CodingKeys type and if it isn't otherwise defined, generate one. This introduces a few weird edge cases, though:

    • What if you try to reference CodingKeys in a different method? Does that count? (How about in an extension?)
    • What if you're copying and pasting some code between types and accidentally refer to CodingKeys instead of the Keys type you created for your type? Now your code is accidentally using a type which you didn't expect and it might just compile due to successful synthesis, but you'd get unexpected results if your Keys type customized the cases
    • What if you write code that refers to some other CodingKeys enum (e.g. if for some reason you have a top-level CodingKeys type, or if you're in a subclass of a customized Codable class)? Depending on the semantics we choose, it could be that you'd now be synthesizing a CodingKeys type when instead you meant to refer to a different type, or vice versa — you want to synthesize a CodingKeys type but some other type is preventing it)

    From an implementation perspective, there's another difficulty here, which is that currently, synthesis in the compiler happens on demand. Derivation of methods happens only when you have a type which doesn't meet protocol requirements, and those requirements are candidates for synthesis (e.g. your type is Decodable but you don't implement init(from:)). As mentioned before, CodingKeys is not a requirement of the Codable protocols, so from the compiler's perspective, if you implement init(from:) and encode(to:), you meet the protocol requirements and there's nothing to synthesize. Synthesizing CodingKeys anyway would require invasive changes to the system. Implementation detail isn't an excuse to not do something, of course—the compiler changes with the design of the language, and not vice versa—but this is nontrivial work that we'd need to do.

All in all, we tended toward simple rules here, both for the compiler and for the API consumer: if your type is Encodable and implements encode(to:), or if your type is Decodable and implements init(from:), it's up to you to also customize CodingKeys by implementing it yourself.


Two quick things to note:

  1. Yes, if your type is Codable and you implement either init(from:) or encode(to:), the compiler can still synthesize a CodingKeys type and implement the other method for you. This is to allow you to override some behavior without totally losing the other synthesized method if it doesn't need to be customized. One thing to keep in mind is that there's nothing preventing a mismatch between you using a custom Keys type on encode(to:) but the synthesized CodingKeys in a synthesized init(from:) and vice versa; on the one hand, this would allow you to totally customize behavior on one side (say, easier data migration on init(from:) while keeping encode(to:) up-to-date), but on the other, this can be a bit surprising
  2. I'm assuming the code you have above is just example code, but it's a really bad idea to "override" init(from:) and encode(to:) in extensions this way because it Doesn't Do What You Think. The type still gets an underlying init(from:)/encode(to:) depending on where that extension lives, and which version of init(from:) or encode(to:) is called depends on whether the extension is visible at a given call site or not. This is true for all methods in Swift, not just init(from:)/encode(to:), and it's not particularly safe to do
4 Likes

Thanks for your explanation I understand better. A quick Idea I have is to use [De | En] Codable to use a custom CodingKey and if used without the custom type, provide a default one, but I don't know if it is a good idea, as it requires big changes on the current implementation that is already great for most cases.