How to pass CodingKeys into a function outside a class, as an array not an enum?

105 views Asked by At

I have implemented custom JSON decoding for some classes in my project. I've set up the usual CodingKeys enum and implemented custom encode and decode functions. This all works fine.

But I've also implemented a few convenience functions for the decoding process that catch any errors thrown by JSONDecoder's KeyedDecodingContainer and return something sane. Because these functions don't need to vary between classes, I want to move them out of the class but most examples define the coding keys as hard-coded enums in the class.

For those asking: There's a good reason that the members are not optionals: You can't bind to optionals in SwiftUI, so you wouldn't be able to display this structure in a UI for editing.

Here's the class to be decoded:

class User : Equatable, Codable
{
    var ID: String = ""
    var pw: String = ""
    var username:  String = ""
    var firstName: String = ""
    var lastName: String = ""
    var EMail: String = ""
    var phoneNbr: String = ""
    var avatarURL: String = ""
    var mediaServiceID: String = ""
    var validated: Bool = false // E-mail is confirmed.

    // Equatable
    static func == (lhs: User, rhs: User) -> Bool
    {
        return lhs.ID == rhs.ID
    }

    // Codable
    enum CodingKeys: String, CodingKey
    {
        case ID = "ID"
        case pw = "pw"
        case username = "username"
        case firstName = "firstName"
        case lastName = "lastName"
        case EMail = "EMail"
        case phoneNbr = "phoneNbr"
        case avatarURL = "avatarURL"
        case mediaServiceID = "mediaServiceID"
        case validated = "validated"
    }

    required init(from decoder: Decoder) throws
    {
        var container: KeyedDecodingContainer<CodingKeys>
        do
        {
            container = try decoder.container(keyedBy: CodingKeys.self)
        }
        
        ID =                safeStringDecode(container: container, forKey: .ID)
        pw =                safeStringDecode(container: container, forKey: .pw)
        username =          safeStringDecode(container: container, forKey: .username)
        firstName =         safeStringDecode(container: container, forKey: .firstName)
        lastName =          safeStringDecode(container: container, forKey: .lastName)
        EMail =             safeStringDecode(container: container, forKey: .EMail)
        phoneNbr =          safeStringDecode(container: container, forKey: .phoneNbr)
        avatarURL =         safeStringDecode(container: container, forKey: .avatarURL)
        mediaServiceID =    safeStringDecode(container: container, forKey: .mediaServiceID)
        validated =         safeBoolDecode(container: container, forKey: .validated)
    }

    func safeStringDecode(container: KeyedDecodingContainer<CodingKeys>, forKey: CodingKeys) -> String
    {
        var result: String
        do
        {
            result = try container.decode(String.self, forKey: forKey)
        }
        catch
        {
            result = ""
        }
        
        return result
    }
    
    func safeBoolDecode(container: KeyedDecodingContainer<CodingKeys>, forKey: CodingKeys) -> Bool
    {
        // First try int, because that's what MySQL/MariaDB return for bools.
        var result: Int
        do
        {
            result = try container.decode(Int.self, forKey: forKey)
            return result == 1
        }
        catch
        {
            // Let's see if it's a 'true'/'false' string.
            var stringResult: String
            do
            {
                stringResult = try container.decode(String.self, forKey: forKey)
                return stringResult == "true"
            }
            catch
            {
                return false
            }
        }
    }

I want to move those safeDecode methods out of this class and into a general utility namespace. But that means I can't hard-code the coding keys as an enum in the class, so I need to pass them in as an array or dictionary. It seems like it should be simple, but thus far I haven't seen a succinct solution. #3 in this post seems close, but I don't really understand some of the logic: How do I use custom keys with Swift 4's Decodable protocol?

The rationale for the "safeDecode" functions was that decoding the object would fail entirely if any of the values were missing or nil, which is entirely possible because those columns are nullable in my database. To prevent that without the convenience functions, I thought I'd have to couch the decoding attempt for every member in a separate do/catch pair... incredibly tedious. But I was unaware of the try? syntax to catch this line-by-line. Thus now I only need the Boolean helper to handle different databases' Boolean types. I put that in a KeyedDecodingContainer extension as @Alexander kindly suggested.

1

There are 1 answers

9
Alexander On BEST ANSWER

Preface: I would strongly caution against providing default values like this. If you end up with an object like User(username: "", firstName: "", lastName: "", EMail: "", phoneNbr: "", ...), what's safe about that? It's nonsense that these helper functions have allowed to silently slip through the cracks.

Nonetheless, I'll answer the question more generally, to show how code like this can be shared in Swift.

Approach 1: mix it in with a protocol extension

// An empty marker protocol, which requires the conforming type to
// be decodable
protocol DecodingHelpers: Decodable {
  associatedtype CodingKeys: CodingKey
}

// Mark `User` with the protocol
extension User: DecodingHelpers {}

// Extend all `DecodingHelpers`-conforming types with the helper functions
extension DecodingHelpers {
  func safeStringDecode(container: KeyedDecodingContainer<CodingKeys>, forKey: CodingKeys) -> String {
    (try? container.decode(String.self, forKey: forKey)) ?? ""
  }
  
  func safeBoolDecode(container: KeyedDecodingContainer<CodingKeys>, forKey: CodingKeys) -> Bool {
    do {
      // First try int, because that's what MySQL/MariaDB return for bools.
      return try container.decode(Int.self, forKey: forKey) == 1
    }
    catch {
      do {
        // Let's see if it's a 'true'/'false' string.
        return try container.decode(String.self, forKey: forKey) == "true"
      }
      catch {
        return false
      }
    }
  }
}

Approach 2: Move these to an extension

Instead of dumping these helpers as methods on all the models that use them, you can instead just change them into extensions on KeyedDecodingContainer:

extension KeyedDecodingContainer {
  func safeStringDecode(forKey: K) -> String {
    (try? decode(String.self, forKey: forKey)) ?? ""
  }
  
  func safeBoolDecode(forKey: K) -> Bool {
    do {
      // First try int, because that's what MySQL/MariaDB return for bools.
      return try decode(Int.self, forKey: forKey) == 1
    }
    catch {
      do {
        // Let's see if it's a 'true'/'false' string.
        return try decode(String.self, forKey: forKey) == "true"
      }
      catch {
        return false
      }
    }
  }
}

// Usage:
class User: Equatable, Codable
  // ...

  required init(from decoder: Decoder) throws {
    let container =  try decoder.container(keyedBy: CodingKeys.self)
    
    ID             = container.safeStringDecode(forKey: .ID)
    pw             = container.safeStringDecode(forKey: .pw)
    username       = container.safeStringDecode(forKey: .username)
    firstName      = container.safeStringDecode(forKey: .firstName)
    lastName       = container.safeStringDecode(forKey: .lastName)
    EMail          = container.safeStringDecode(forKey: .EMail)
    phoneNbr       = container.safeStringDecode(forKey: .phoneNbr)
    avatarURL      = container.safeStringDecode(forKey: .avatarURL)
    mediaServiceID = container.safeStringDecode(forKey: .mediaServiceID)
    validated      = container.safeBoolDecode(forKey: .validated)
  }
}

Approach 3: Use property wrappers

This is my favourite approach, because it can completely remove the need to define a custom init(from:) initializer!

Check out the Default macro from the MetaCodable package, for reference: https://github.com/SwiftyLab/MetaCodable/blob/a6011c3337f573b04b29b8591b507de7e6e4ed8d/Sources/MetaCodable/Default.swift