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

6

9

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.

Ginkgo answered 6/11, 2018 at 3:14 Comment(8)
https://mcmap.net/q/269306/-codable-enum-with-default-case-in-swift-4 relatedVolk
@LeoDabus interesting... But I bet you can not assign nil to self inside a non-failable, throwing initializer... That answer assigns a default actual value...Ginkgo
You should throw or fail not both. And btw to fail silently it is not an optionVolk
I know. I want the possibility to fail and have nil assigned to my optional property, but Decodable works by throwing...Ginkgo
try class MyClass: Codable { enum Company: Int, Codable { case toyota = 1, ford, gm } var company: Company? required init(from decoder: Decoder) throws { company = try Company(rawValue: decoder.singleValueContainer().decode(Int.self)) } }Volk
@LeoDabus, Yes, I know that would work but that is exactly what I was trying to avoid (to have to explicitly implement init(from decoder: Decoder)); my class has a lot of properties besides company...Ginkgo
The only thing I can think off is to parse the coming json company as a regular Integer and create a computed property to get its enumeration value as you already showed in your questionVolk
@NicolasMiari What about to use just try? ....? You can write if let propertyName = try? .... { do what you need }. You dont actually need init?(from decoder: Decoder).Tori
G
2

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...

Ginkgo answered 6/11, 2018 at 7:2 Comment(0)
S
8

From swift 5 you can use Property Wrappers. https://docs.swift.org/swift-book/LanguageGuide/Properties.html

In your case main struct will be like:

@propertyWrapper
public struct NilOnFailCodable<ValueType>: Codable where ValueType: Codable {

    public var wrappedValue: ValueType?

    public init(wrappedValue: ValueType?) {
        self.wrappedValue = wrappedValue
    }

    public init(from decoder: Decoder) throws {
        self.wrappedValue = try? ValueType(from: decoder)
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        if let value = wrappedValue {
            try container.encode(value)
        } else {
            try container.encodeNil()
        }
    }
}

Usage

struct Model: Codable {
    @NilOnFailCodable var val: Enum?
    enum Enum: Int, Codable {
        case holdUp = 0
        case holdDown = 1
    }
}

And example

let encoder = JSONEncoder()
let decoder = JSONDecoder()
let s = #"{"val": 2}"#
let data = s.data(using: .utf8)
let dec = decoder.decode(Model.self, from: data!)
print(dec)
let enc = encoder.encode(dec)
print(decoder.decode(Model.self, from: enc))

Will print

Model(_val: NilOnFailCodable<Model.Enum>(wrappedValue: nil))
nil
Model(_val: NilOnFailCodable<Model.Enum>(wrappedValue: nil))
nil

And for value "val": 1

Model(_val: NilOnFailCodable<Model.Enum>(wrappedValue: Optional(Model.Enum.holdDown)))
Optional(1)
Model(_val: NilOnFailCodable<Model.Enum>(wrappedValue: Optional(Model.Enum.holdDown)))
Optional(1)

If the key "val" is not present at all, it will fail to decode. Add the code below to fix this error:

public extension KeyedDecodingContainer {
    func decode<T: Codable>(_ type: NilOnFailCodable<T>.Type, forKey key: Self.Key) throws -> NilOnFailCodable<T> {
        return try decodeIfPresent(type, forKey: key) ?? NilOnFailCodable(wrappedValue: nil)
    }
}
Slough answered 20/8, 2021 at 15:3 Comment(0)
K
5

I think I had a similar issue to yours, if I'm understanding correctly. In my case, I wrote a wrapper for each enum in question:

struct NilOnFail<T>: Decodable where T: Decodable {

    let value: T?

    init(from decoder: Decoder) throws {
        self.value = try? T(from: decoder) // Fail silently
    }

    // TODO: implement Encodable
}

Then use it like this:

class MyClass: Codable {

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

    var company: NilOnFail<Company>
...

The caveat being, of course, that wherever you need to access the value of company you need to use myClassInstance.company.value

Kiernan answered 6/11, 2018 at 4:0 Comment(2)
I see... at least my workaround leaves the public interface of MyClass intact.Ginkgo
I think perhaps I was looking for a way to extend or subclass JSONDecoder so that it can handle the failable initialization of the enum... although I have trouble seeing how that could be done.Ginkgo
G
2

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...

Ginkgo answered 6/11, 2018 at 7:2 Comment(0)
G
1

I know my answer is late, but maybe it will help someone else.

I also had String Optional enums, but if I got from backend a new value that was not covered in local enum, the json would not get parsed - even if the enum was optional.

This is how I fixed it, no need to implement any init method. This way you can also provide default values instead of nil, if necessary.

struct DetailView: Codable {

var title: ExtraInfo?
var message: ExtraInfo?
var action: ExtraInfo?
var imageUrl: String?

// 1
private var imagePositionRaw: String?
private var alignmentRaw: String?

// 2
var imagePosition: ImagePosition {
    ImagePosition.init(optionalRawValue: imagePositionRaw) ?? .top
}

// 3
var alignment: AlignmentType? {
    AlignmentType.init(optionalRawValue: alignmentRaw)
}

enum CodingKeys: String, CodingKey {
    case imagePositionRaw = "imagePosition",
         alignmentRaw = "alignment",
         imageUrl,
         title,
         message,
         action
}

}

(1) You get the values from backend as raw (string, int - whatever you need) and you init your enums from those raw values (2,3).

If the value from backend is nil or a different value from what you would expect, you return nil (3) or a default value (2).

--- edit to add the extension used for enum init:

extension RawRepresentable {
  init?(optionalRawValue: RawValue?) {
    guard let rawData = optionalRawValue else { return nil }
    self.init(rawValue: rawData)
  }
}
Gingras answered 21/7, 2021 at 9:29 Comment(0)
E
0

You could try SafeDecoder

import SafeDecoder

class MyClass: Codable {

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

Then just decode as unusual. Any value other than 1,2,3 will fallback to nil automatically.

Exaggerative answered 15/1, 2020 at 6:35 Comment(0)
D
0

Thanks for your detailed question and answer. You made me rethink my approach to decoding JSON. Had similar problem and decided to decode JSON value to Int rather than adding logic to what supposed to be DTO. After that adding extension of model to convert value to enum makes no difference from perspective of using enum but looks to be a cleaner solution.

Dorado answered 14/3, 2021 at 11:38 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.