Skip to content

Instantly share code, notes, and snippets.

@christianselig
Last active October 27, 2022 18:55
Show Gist options
  • Save christianselig/92b75f6bc94a76b8ab2a45b0115b39c2 to your computer and use it in GitHub Desktop.
Save christianselig/92b75f6bc94a76b8ab2a45b0115b39c2 to your computer and use it in GitHub Desktop.

If I have a JSON Codable object in Swift that looks like:

struct IceCreamStore: Codable {
    let iceCreams: [IceCream: Int]
}

enum IceCream: String, Codable {
    case chocolate, vanilla
}

And I accidentally encoded a strawberry field from a new version of the app, so that the dictionary now has a strawberry field in it, that this older version of the app doesn't know how to deal with (and thus can't decode it and errors), is there a way to conditionally decode it and just ignore that strawberry value? I tried using CodingKeys and decoding it as a [String: IceCreamInfo] instead manually, but no dice, still won't decode as that. I don't want to add strawberry manually as a value for a number of reasons but just consider those academic.

It's basically trying to ingest:

{
    "chocolate": 8,
    "vanilla": 4,
    "strawberry": 3
}

When it doesn't know how to deal with strawberry, and I want to just have it ignore the strawberry.

Like, in my head I want to do:

let container = try decoder.container(keyedBy: CodingKeys.self)
let manualDictionary = try container.decode([String: Int].self, forKey: .iceCreams)

var realDictionary: [IceCream: Int] = [:]

for key, value in manualDictionary {
    guard let iceCream = IceCream(rawValue: key) else { continue }
    realDictionary[iceCream] = value
}

self.iceCreams = realDictionary

But that's not working (it won't let me decode it as a [String: Int]).

@damirstuhec
Copy link

You're failing to decode because dictionaries with custom key types are encoded as arrays. This is strange indeed but expected. You can read more about it here.

@eliyap
Copy link

eliyap commented Oct 26, 2022

You're failing to decode because dictionaries with custom key types are encoded as arrays. This is strange indeed but expected. You can read more about it here.

Based on that article, I have:

import Foundation

enum IceCream: String, Decodable {
  case chocolate
  case vanilla
}

public struct DictionaryWrapper: Decodable {
  var dictionary: [IceCream: Int]

  init(dictionary: [IceCream: Int]) {
    self.dictionary = dictionary
  }

  public init(from decoder: Decoder) throws {
    let container = try decoder.singleValueContainer()
    let stringDictionary = try container.decode([String: Int].self)

    dictionary = [:]
    for (stringKey, value) in stringDictionary {
      if let key = IceCream(rawValue: stringKey) {
        dictionary[key] = value
      } else { 
        print("Unexpected key: \(stringKey)")
      }
    }
  }
}

// Decoding
let jsonData = """
{
 "chocolate":  0,
 "vanilla": 1,
 "strawberry": 2
}
""".data(using: .utf8)!
let decoded = try JSONDecoder().decode(DictionaryWrapper.self, from: jsonData)

print(decoded.dictionary)

@jegnux
Copy link

jegnux commented Oct 27, 2022

The issue is not really the decode but the encode. Encodable doesn't encode [KeyType: ValueType] as { "key" : value } even if KeyType is RawRepresentable where RawValue == String. If you read the json made out from your encode function, you'll see you really get an array alternating keys and values:

enum IceCream: String, Codable {
    case chocolate, vanilla, strawberry
}

let store = IceCreamStore(iceCreams: [
    .chocolate: 8,
    .vanilla: 9,
    .strawberry: 10
])

let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
    
let data = try! encoder.encode(store)
print(String(data: data, encoding: .utf8)!)
{
  "iceCreams" : [
    "vanilla",
    9,
    "strawberry",
    10,
    "chocolate",
    8
  ]
}

So the idea is to have a custom init(from decoder: Decoder) AND a custom encode(to encoder: Encoder):

struct IceCreamStore {
    let iceCreams: [IceCream: Int]
}

extension IceCreamStore: Codable {
    enum CodingKeys: String, CodingKey {
        case iceCreams
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        iceCreams = Dictionary(
            uniqueKeysWithValues: try container
            .decode([String: Int].self, forKey: .iceCreams)
            .compactMap { key, value in 
                guard let iceCream = IceCream(rawValue: key) else { return nil }
                return (iceCream, value)
            }
        )
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(Dictionary(
            uniqueKeysWithValues: iceCreams.map { iceCream, value in 
                (iceCream.rawValue, value)
            }
        ), forKey: .iceCreams)
    }
}

Which have the correct, expected, output:

let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
    
let data = try! encoder.encode(store)
print(String(data: data, encoding: .utf8)!)
{
  "iceCreams" : {
    "chocolate" : 8,
    "vanilla" : 9,
    "strawberry" : 10
  }
}

Edit: on Swift 5.6, you don't need the custom encode(to encoder: Encoder). You can simply add CodingKeyRepresentable conformance on IceCream (and keep the custom init(from decoder: Decoder) though):

enum IceCream: String, Codable, CodingKeyRepresentable {
    case chocolate, vanilla, strawberry
}

see SE-0320 Allow coding of non String / Int keyed Dictionary into a KeyedContainer

@christianselig
Copy link
Author

Thanks y'all @jegnux @eliyap @damir @quinwoods this helped a ton :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment