Search code examples
swiftenumsoption-typecodable

Swift Codable - How to Initialize an Optional Enum Property in a Failable Manner


I'm trying to adopt the Codable protocol for an object that must be instantiated from a JSON my web service returns in response to one of the API calls.

One of the properties is of enumeration type, and optional: nil means that none of the options defined by the enum has been chosen.

The enum constants are Int-based and start at 1, not 0:

class MyClass: Codable {

    enum Company: Int {
        case toyota = 1
        case ford
        case gm
    } 
    var company: Company?

This is because the value 0 on the corresponding JSON entry is reserved for "not set"; i.e. it should be mapped to nil when setting initializing the company property from it.

Swift's enum initializer init?(rawValue:) provides this functionality out-of-the-box: an Int argument that does not match the raw value of any case will cause the initializer to fail and return nil. Also, Int (and String) -based enums can be made to conform to Codable just by declaring it in the type definition:

enum Company: Int, Codable {
    case toyota = 1
    case ford
    case gm
} 

The problem is, my custom class has more than 20 properties, so I really really want to avoid having to implement init(from:) and encode(to:), relying instead on the automatic behavior obtained by providing the CondingKeys custom enumeration type.

This results in initialization of the whole class instance failing because it seems the "synthesized" initializer cannot infer that an unsupported raw value of the enum type should be treated as nil (even though the target property is clearly marked as optional, i.e. Company?).

I think this is so because the initializer provided by Decodable can throw, but it can not return nil:

// This is what we have:
init(from decoder: Decoder) throws

// This is what I would want:
init?(from decoder: Decoder)

As a workaround, I have implemented my class as follows: map the JSON's integer property into a private, stored Int property of my class that serves as storage only, and introduce a strongly-typed computed property that acts as a bridge between the storage and the rest of my app:

class MyClass {

   // (enum definition skipped, see above)

   private var companyRawValue: Int = 0

   public var company: Company? {
       set {
           self.companyRawValue = newValue?.rawValue ?? 0
           // (sets to 0 if passed nil)
       }
       get {
           return Company(rawValue: companyRawValue)
           // (returns nil if raw value is 0)
       }
   }

   enum CodingKeys: String, CodingKey {
       case companyRawValue = "company"
   }

   // etc...

My question is: Is there a better (simpler/more elegant) way, that:

  1. Does not require duplicated properties like my workaround, and
  2. Does not require fully implementing init(from:) and/or encode(with:), perhaps implementing simplified versions of these that delegate to the default behavior for the most part (i.e. do not require the whole boilerplate of manually initializing/encoding each and every property)?

Addendum: There's a third, also inelegant solution that didn't occur to me when I first posted the question. It involves using an artificial base class just for the sake of automatic decoding. I will not use it, but just describe it here for the sake of completeness:

// Groups all straight-forward decodable properties
//
class BaseClass: Codable {
    /*
     (Properties go here)
     */

    enum CodingKeys: String, CodingKey {
        /*
         (Coding keys for above properties go here)
         */
    }

    // (init(from decoder: Decoder) and encode(to:) are 
    // automatically provided by Swift)
}

// Actually used by the app
//
class MyClass: BaseClass {

    enum CodingKeys: String, CodingKey {
        case company
    }

    var company: Company? = nil

    override init(from decoder: Decoder) throws {
        super.init(from: decoder)

        let values = try decoder.container(keyedBy: CodingKeys.self)
        if let company = try? values.decode(Company.self, forKey: .company) {
            self.company = company
        }

    }
}

...But this is a really ugly hack. Class inheritance hierarchy shouldn't be dictated by this type of shortcomings.


Solution

  • After searching the documentation for the Decoder and Decodable protocols and the concrete JSONDecoder class, I believe there is no way to achieve exactly what I was looking for. The closest is to just implement init(from decoder: Decoder) and perform all the necessary checks and transformations manually.


    Additional Thoughts

    After giving some thought to the problem, I discovered a few issues with my current design: for starters, mapping a value of 0 in the JSON response to nil doesn't seem right.

    Even though the value 0 has a specific meaning of "unspecified" on the API side, by forcing the failable init?(rawValue:) I am essentially conflating all invalid values together. If for some internal error or bug the server returns (say) -7, my code won't be able to detect that and will silently map it to nil, just as if it were the designated 0.

    Because of that, I think the right design would be to either:

    1. Abandon optionality for the company property, and define the enum as:

      enum Company: Int {
         case unspecified = 0
         case toyota
         case ford
         case gm
      }
      

      ...closely matching the JSON, or,

    2. Keep optionality, but have the API return a JSON that lacks a value for the key "company" (so that the stored Swift property retains its initial value of nil) instead of returning 0 (I believe JSON does have a "null" value, but I'm not sure how JSONDecoder deals with it)

    The first option requires to modify a lot of code around the whole app (changing occurrences of if let... to comparisons against .unspecified).

    The second option requires modifying the server API, which is beyond my control (and would introduce a migration/ backward compatibility issue between server and client versions).

    I think will stick with my workaround for now, and perhaps adopt option #1 some time in the future...