Encoding and decoding Swift dictionaries with custom key types

Pretty much every project out there nowadays uses Codable, introduced in 2017 with Swift 4.

Fast forward to 2021 and Codable can still surprise us:
in this article let's have a look at a little known behavior when it comes to dictionaries with custom key types.

We will focus on json examples, the same can be applied to plists.

Dictionary 101

Swift dictionaries are a generic collection of key-value elements, where the Key type needs to conform to Hashable (for performance reasons), and the Value type has no restrictions.

Here's an example with four key-value pairs:

let dictionary: [String: Int] = [
  "f": 1,
  "i": 2,
  "v": 3,
  "e": 4
]

When it comes to json, the same dictionary above will have the following format:

{
  "f": 1,
  "i": 2,
  "v": 3,
  "e": 4
}

Where the keys must be quoted.

The surprise

Let's imagine that we're building a small app where each model has different names in different languages, all to be displayed to the user at will.

A way to store all name variants is to introduce a new Codable Language type, to be used as the dictionary key for the name of our models:

enum Language: String, Codable {
  case english
  case japanese
  case thai
}

Then we'd use it this way:

let names: [Language: String] = [
  .english:  "Victory Monument",
  .japanese: "戦勝記念塔",
  .thai:     "อนุสาวรีย์ชัยสมรภูมิ"
]

This is all great...until it's time to encode it to json:

let encodedDictionary = try JSONEncoder().encode(names)
// ["english", "Victory Monument", "japanese", "戦勝記念塔", "thai", "อนุสาวรีย์ชัยสมรภูมิ"]

That's...an array, not exactly a dictionary.

What about decoding it?

let jsonData = """
{
 "english":  "Victory Monument",
 "japanese": "戦勝記念塔",
 "thai":     "อนุสาวรีย์ชัยสมรภูมิ"
}
""".data(using: .utf8)!

let decoded = try JSONDecoder().decode([Language: String].self, from: jsonData)
// typeMismatch(
//   Swift.Array<Any>, 
//   Swift.DecodingError.Context(
//     codingPath: [],
//     debugDescription: "Expected to decode Array<Any> but found a dictionary instead.", 
//     underlyingError: nil
//    )
//  )

The decoding fails: despite trying to decode into a dictionary ([Language: String]), Swift was expecting to decode an array.

If we replace the key type Language with String, then everything works as expected: why is that?

After some digging it turns out that this behavior is actually expected:

- as a Codable type can encode into anything (including another dictionary), Swift will encode Swift dictionaries into json/plist dictionaries only when the Key type is String or Int.

  • all other Swift dictionaries with non-String or non-Int Key types will be encoded into an array of alternating keys and values.

This explain both the error in the decoding above and the "unexpected" encoding.

Four solutions

At this point we know that Swift dictionaries will encode into/decode from json dictionaries only when its key is either a String or a Int, how can we overcome this in our example? Let's see a few solutions.

Bend to Swift's way

The first is to give up on the json dictionary representation of our model, and embrace the expected alternating keys and values array:

// Encoding
let names: [Language: String] = [
  .english:  "Victory Monument",
  .japanese: "戦勝記念塔",
  .thai:     "อนุสาวรีย์ชัยสมรภูมิ"
]

let encoded = try JSONEncoder().encode(names)
// ["english", "Victory Monument", "japanese", "戦勝記念塔", "thai", "อนุสาวรีย์ชัยสมรภูมิ"]

// Decoding
let jsonData = """
[
 "english",  "Victory Monument",
 "japanese", "戦勝記念塔",
 "thai",     "อนุสาวรีย์ชัยสมรภูมิ"
]
""".data(using: .utf8)!

let decoded = try JSONDecoder().decode([Language: String].self, from: jsonData)
// [
//   .english: "Victory Monument",
//   .japanese: "戦勝記念塔", 
//   .thai: "อนุสาวรีย์ชัยสมรภูมิ"
// ]

This works great when it's the app that both stores and reads the data:
at this point we do not really care how the data is stored, Swift will take care of both the encoding and decoding for us.

Use Int/String

While the above solution works great when the data is used only locally, for probably most uses the json object will come from a server, meaning that we will receive a json dictionary.

The easiest way to patch this without changing the expected json structure is to use String or Int instead of our custom type:

// Encoding
let names: [String: String] = [
  "english":  "Victory Monument",
  "japanese": "戦勝記念塔",
  "thai":     "อนุสาวรีย์ชัยสมรภูมิ"
]
let encodedDictionary = try JSONEncoder().encode(names)
// {
//   "english":  "Victory Monument",
//   "japanese": "戦勝記念塔",
//   "thai":     "อนุสาวรีย์ชัยสมรภูมิ"
// }

// Decoding
let jsonData = """
{
 "english":  "Victory Monument",
 "japanese": "戦勝記念塔",
 "thai":     "อนุสาวรีย์ชัยสมรภูมิ"
}
""".data(using: .utf8)!
let decoded = try JSONDecoder().decode([String: String].self, from: jsonData)
// [
//   "english":  "Victory Monument",
//   "japanese": "戦勝記念塔",
//   "thai":     "อนุสาวรีย์ชัยสมรภูมิ"
// ]

However this would mean:

  • giving up on our clear declaration/expectation, a.k.a. the list of possible cases in the Language example
  • introducing the possibility of invalid/unexpected keys (both from and to the server).

So far neither solution is ideal in the general case, let's see what we can do next.

Custom encoding/Decoding

There's no way around it:
if we want to encode-into/decode-from a json dictionary, we will have to go through a Swift dictionary with either String or Int as its Key type.

With this being said, we could use such key types just for encoding/decoding, but then store it in Swift using our custom type.

Let's create a new wrapper around our dictionary, lazily named DictionaryWrapper, which will:

  • turn our keys into Strings before encoding them
  • decode a [String: String] dictionary and then turn into a [Language: String] dictionary
public struct DictionaryWrapper: Codable {
  var dictionary: [Language: String]

  init(dictionary: [Language: String]) {
    self.dictionary = dictionary
  }

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

    dictionary = [:]
    for (stringKey, value) in stringDictionary {
      guard let key = Language(rawValue: stringKey) else {
        throw DecodingError.dataCorruptedError(
          in: container,
          debugDescription: "Invalid key '\(stringKey)'"
        )
      }
      dictionary[key] = value
    }
  }

  public func encode(to encoder: Encoder) throws {
    let stringDictionary: [String: String] = Dictionary(
      uniqueKeysWithValues: dictionary.map { ($0.rawValue, $1) }
    )
    var container = encoder.singleValueContainer()
    try container.encode(stringDictionary)
  }
}

Thanks to this definition:

  • we can no longer encode/decode invalid keys
  • match our original expectations

We can now go back to the original example and see that this new definition will work as expected:

// Encoding
let names = DictionaryWrapper(
  dictionary: [
    .english:  "Victory Monument",
    .japanese: "戦勝記念塔",
    .thai:     "อนุสาวรีย์ชัยสมรภูมิ"
  ]
)
let encodedDictionary = try JSONEncoder().encode(names)
// {
//   "english":  "Victory Monument",
//   "japanese": "戦勝記念塔",
//   "thai":     "อนุสาวรีย์ชัยสมรภูมิ"
// }

// Decoding
let jsonData = """
{
 "english":  "Victory Monument",
 "japanese": "戦勝記念塔",
 "thai":     "อนุสาวรีย์ชัยสมรภูมิ"
}
""".data(using: .utf8)!
let decoded = try JSONDecoder().decode(DictionaryWrapper.self, from: jsonData)
// [
//   .english: "Victory Monument",
//   .japanese: "戦勝記念塔", 
//   .thai: "อนุสาวรีย์ชัยสมรภูมิ"
// ]

RawRapresentable

The last solution worked perfectly, however it was hard coded for a specific dictionary type. A way to expand it to the more general case is to use Swift's RawRepresentable protocol, which represents a type that can be converted to and from an associated raw value:

public protocol RawRepresentable {
  /// The raw type that can be used to represent all values of the conforming type.
  associatedtype RawValue

  /// Creates a new instance with the specified raw value.
  init?(rawValue: Self.RawValue)

  /// The corresponding value of the raw type.
  var rawValue: Self.RawValue { get }
}

We can define a generic DictionaryWrapper where we require the dictionary Key to conform to RawRepresentable, and where we will use its associated RawValue type for the dictionary encoding/decoding:

public struct DictionaryWrapper<Key: Hashable & RawRepresentable, Value: Codable>: Codable where Key.RawValue: Codable & Hashable {
  public var dictionary: [Key: Value]

  public init(dictionary: [Key: Value]) {
    self.dictionary = dictionary
  }

  public init(from decoder: Decoder) throws {
    let container = try decoder.singleValueContainer()
    let rawKeyedDictionary = try container.decode([Key.RawValue: Value].self)

    dictionary = [:]
    for (rawKey, value) in rawKeyedDictionary {
      guard let key = Key(rawValue: rawKey) else {
        throw DecodingError.dataCorruptedError(
          in: container,
          debugDescription: "Invalid key: cannot initialize '\(Key.self)' from invalid '\(Key.RawValue.self)' value '\(rawKey)'")
      }
      dictionary[key] = value
    }
  }

  public func encode(to encoder: Encoder) throws {
    let rawKeyedDictionary = Dictionary(uniqueKeysWithValues: dictionary.map { ($0.rawValue, $1) })
    var container = encoder.singleValueContainer()
    try container.encode(rawKeyedDictionary)
  }
}

I couldn't find a way to restrict this exclusively for Key.RawValue == String or Key.RawValue == Int, mainly because of Swift's ban on overlapping conformances, if you find a way, please let me know.

Thanks to this new definition DictionaryWrapper will encode any Swift dictionary into a json/plist dictionary as long as the key used has an associated RawValue of type String or Int.

Bonus: a property wrapper!

Credits to Jarrod Davis for this solution.

Most likely our dictionary will be part of a model that needs to be encoded/decoded. Instead of adding DictionaryWrapper as part of each property type definition, we can also update DictionaryWrapper definition by:

  • adding the @propertyWrapper attribute to our DictionaryWrapper declaration
  • replacing the internal dictionary property name with wrappedValue

That's all it's needed to make DictionaryWrapper a property wrapper:

@propertyWrapper
public struct DictionaryWrapper<Key: Hashable & RawRepresentable, Value: Codable>: Codable where Key.RawValue: Codable & Hashable {
  public var wrappedValue: [Key: Value]

  public init() {
    wrappedValue = [:]
  }

  public init(wrappedValue: [Key: Value]) {
    self.wrappedValue = wrappedValue
  }

  public init(from decoder: Decoder) throws {
    let container = try decoder.singleValueContainer()
    let rawKeyedDictionary = try container.decode([Key.RawValue: Value].self)

    wrappedValue = [:]
    for (rawKey, value) in rawKeyedDictionary {
      guard let key = Key(rawValue: rawKey) else {
        throw DecodingError.dataCorruptedError(
          in: container,
          debugDescription: "Invalid key: cannot initialize '\(Key.self)' from invalid '\(Key.RawValue.self)' value '\(rawKey)'")
      }
      wrappedValue[key] = value
    }
  }

  public func encode(to encoder: Encoder) throws {
    let rawKeyedDictionary = Dictionary(uniqueKeysWithValues: wrappedValue.map { ($0.rawValue, $1) })
    var container = encoder.singleValueContainer()
    try container.encode(rawKeyedDictionary)
  }
}

With this change, we can go on and declare any model with this new property wrapper:

struct FSModel: Codable {
  @DictionaryWrapper var names: [Language: String]
  ...
}

...and then access directly to the dictionary without having to go thorough DictionaryWrapper:

let jsonData = """
{
 "names": {
   "english": "Victory Monument",
   "japanese": "戦勝記念塔",
   "thai":     "อนุสาวรีย์ชัยสมรภูมิ"
  },
  ...
}
""".data(using: .utf8)!

let model = try JSONDecoder().decode(FSModel.self, from: jsonData)
model.names // [Language: String]
// [
//   .english: "Victory Monument",
//   .japanese: "戦勝記念塔", 
//   .thai: "อนุสาวรีย์ชัยสมรภูมิ"
// ]

Conclusions

The introduction of Codable in Swift was a blessing: despite having a few shortcoming here and there, it's also flexible enough for us to extend it and make it work for our needs.

What challenges have you faced while working with Codable? How did you solve it? Please let me know!

Thank you for reading and stay tuned for more articles!

Further reading

⭑⭑⭑⭑⭑

Further Reading

Explore Swift

Browse all