Skip to content

Instantly share code, notes, and snippets.

@christianselig
Last active October 27, 2022 18:55
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • 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

What's the exact JSON you're trying to decode with the above code?

@christianselig
Copy link
Author

christianselig commented Oct 26, 2022

@damirstuhec It's not actually JSON, I'm just encoding/decoding it with that encoder.

Here's something simple you can drop into a new Xcode proj to see, just follow the instructions to comment out the strawberry stuff for the second launch to simulate not knowing about that value anymore:

import UIKit

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

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

    override func viewDidLoad() {
        super.viewDidLoad()

        // First launch
        let store = IceCreamStore(iceCreams: [
            .chocolate: 8,
            .vanilla: 9,
            .strawberry: 10
        ])
        
        let data = try! JSONEncoder().encode(store)
        UserDefaults.standard.set(data, forKey: "boop")

        // Uncomment for second launch and comment out the above, and comment out the strawberry type in the `IceCream` enum declaration
//        let data = UserDefaults.standard.data(forKey: "boop")!
//
//        do {
//            let result = try JSONDecoder().decode(IceCreamStore.self, from: data)
//            print(result)
//        } catch {
//            print(error)
//        }
    }
}

@christianselig
Copy link
Author

Like, ideally I could do:

struct IceCreamStore: Codable {
    let iceCreams: [IceCream: Int]
    
    enum CodingKeys: String, CodingKey {
        case iceCreams
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        // This line fails with `Expected to decode Dictionary<String, Int> but found an array instead`
        let testing = try container.decode([String : Int].self, forKey: .iceCreams)

        // ...
    }
}

@quindariuss
Copy link

quindariuss commented Oct 26, 2022

I did this recently! Just use

.decodeIfPresent

for the values you want to conditionally decode.
you might have to have manual coding keys for doing that though 😶‍🌫️

@christianselig
Copy link
Author

@quinwoods Could you show how that would work in this example? I understand that conceptually, but it's part of the insides that could/could not be present, not the entire variable itself.

@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