Why Dictionary sometimes encodes itself as an array

Updates:

  1. Dec 6, 2017
    Mentioned a “workaround” at the end. Fixed some typos.

This is a writeup of a Twitter conversation.

You may have noticed that some dictionaries encode themselves as arrays in Swift’s Codable system — or more precisely, as unkeyed containers, which is Codable lingo for an array-like data structure. This is quite surprising; a keyed containers sounds like a much better match for a dictionary.

Here’s an example. Note that the dictionary’s Key type is a String-backed enum:

enum Color: String, Codable {
    case red
    case green
    case blue
}

let dict: [Color : String] = [
    .red: "ff0000",
    .green: "00ff00",
    .blue: "0000ff"
]

import Foundation
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let encoded = try! encoder.encode(dict)
let jsonText = String(decoding: encoded, as: UTF8.self)

This produces a JSON array containing both keys and values in alternating order, and not an object/dictionary of key-value pairs, as I would have expected:

[
  "blue",
  "0000ff",
  "red",
  "ff0000",
  "green",
  "00ff00"
]

Not all dictionaries behave this way, though. If we replace the enum keys with plain strings, the encoder produces a proper JSON object.


So Dictionary seems to behave differently depending on its Key type, even though the enum values are ultimately encoded as strings. What’s going on here? We can find the answer in Dictionary’s implementation for the Encodable protocol. The code looks like this:

extension Dictionary : Encodable /* where Key : Encodable, Value : Encodable */ {
    public func encode(to encoder: Encoder) throws {
        assertTypeIsEncodable(Key.self, in: type(of: self))
        assertTypeIsEncodable(Value.self, in: type(of: self))

        if Key.self == String.self {
            // Since the keys are already Strings, we can use them as keys directly.
            var container = encoder.container(keyedBy: _DictionaryCodingKey.self)
            for (key, value) in self {
                let codingKey = _DictionaryCodingKey(stringValue: key as! String)!
                try (value as! Encodable).__encode(to: &container, forKey: codingKey)
            }
        } else if Key.self == Int.self {
            // Since the keys are already Ints, we can use them as keys directly.
            var container = encoder.container(keyedBy: _DictionaryCodingKey.self)
            for (key, value) in self {
                let codingKey = _DictionaryCodingKey(intValue: key as! Int)!
                try (value as! Encodable).__encode(to: &container, forKey: codingKey)
            }
        } else {
            // Keys are Encodable but not Strings or Ints, so we cannot arbitrarily convert to keys.
            // We can encode as an array of alternating key-value pairs, though.
            var container = encoder.unkeyedContainer()
            for (key, value) in self {
                try (key as! Encodable).__encode(to: &container)
                try (value as! Encodable).__encode(to: &container)
            }
        }
    }
}

(The /* where Key : Encodable, Value : Encodable */ comment indicates constraints that should be there but can’t be expressed in the type system in Swift 4.0. The missing feature is called conditional conformance and will probably land in Swift 4.1 or 5.0. The two assertions at the beginning of the function ensure that the constraints hold at runtime.)

There are three branches: only if the dictionary’s key type is String or Int does it use a keyed container. Any other key type triggers results in an unkeyed container of alternating keys and values.

String and Int get special treatment because those are the two valid coding key types in the Codable world. Any other coding key is ultimately lowered to a String or Int.1 Since the dictionary has no way to tell how other types encode themselves, it has no choice but to resort to an unkeyed container.

And the fact that our custom enum is backed by String (and thus produces String values when encoded) doesn’t matter. The standard library can’t use a rule like “use a keyed container if Key: RawRepresentable, Key.RawValue == String“ because, however unlikely, the type may have overridden the default compiler-synthesized Codable implementation to encode itself differently — there’s no practical way for the standard library to tell.


I don’t think there’s a simple fix for this, short of manually converting any dictionary to String or Int keys before encoding. Here’s how you could do this for the example dictionary (this works for any RawRepresentable dictionary with a RawValue of String or Int):

let stringKeyedDict = Dictionary(
    dict.map { ($0.key.rawValue, $0.value) },
    uniquingKeysWith: { $1 }
)

This creates a new dictionary from a sequence of key-value pairs. We create this sequence by mapping over the source dictionary and using the key’s rawValue property to produce the string keys.

  1. You may wonder why Int is a valid key type when JSON requires string keys. But we’re on a more generic level here. The dictionary doesn’t know it’s being encoded into JSON, it only knows about the generic Encoder interface. And all compatible encoders must be able to handle String and Int keys. It’s then the JSONEncoder’s task to convert any integer keys it receives into strings. ↩︎

If you liked this article, I bet you’ll also like Advanced Swift, the book I wrote together with Chris Eidhof and Airspeed Velocity.

The third edition, fully updated for Swift 4, is out now.

Advanced Swift is available as a DRM-free e-book (including Xcode playgrounds) and in print.