Passing Context Into Swift Codable Using UserInfo

5 min read •

The Problem

I recently encountered a problem where I needed to provide a Codable type with more context than what was available in the JSON payload. This led me to discover the userInfo dictionary on the JSONDecoder type.

For my specific problem I needed to transform values from a server response based on a theme that is only known at runtime. While I could have performed this transformation in a view model or during layout, I wanted to throw a decoding error if the mapping failed. That meant it had to happen earlier, during decoding.

Why Use JSONDecoder UserInfo?

As defined by Apple the userInfo object is "a dictionary you use to customize the decoding process by providing contextual information". So this sounds like it could be perfect for my use case, allowing me to pass in some data to the JSONDecoder to assist with decoding some of the properties. As it is a Dictionary type its also simple to work with and immediately understandable.

It's defined as:

@preconcurrency
var userInfo: [CodingUserInfoKey : any Sendable] { get set }

So as long as the value we want to pass into userInfo is Sendable, this solution should work.

This also works with JSONEncoder, so you can use userInfo when serialising types as well.

Basic Decodable Example

To demonstrate how we can use it, we'll use a simplified example: passing an extra property into a type during decoding, where the property value isn't included in the JSON payload itself. Below we will pass a parentID into our Child decodable type.

Here's the type along with some mock JSON for use in a Playground:

import SwiftUI

struct Child: Decodable {
    let name: String
    let age: Int
//    let parentID: String // not in JSON payload
}

let mockJSONString = """
{
    "name": "Lucy",
    "age": 4
}
"""

do {
	let decoder = JSONDecoder()
    let data = try decoder.decode(Child.self, from: mockJSONString.data(using: .utf8)!)
    print(data)
} catch {
    print(error.localizedDescription)
}

Running this will print:
"Child(name: "Lucy", age: 4)"

Now we want to pass in the parentID, which we already know at the time of decoding; perhaps from another response or in-memory value.

Setting UserInfo

We can add this value to the decoder’s userInfo property. We can really add any type to the dictionary as long as it is Sendable but for this example we are just adding a String.

First we make a key to store our value in the dictionary:

extension CodingUserInfoKey {
    static let parentIDKey = CodingUserInfoKey(rawValue: "parentIDKey")
}

We then use this key to insert a value into the decoder’s userInfo:

note: I have made an error type ParentDecodingError to use here, as I wish to throw an error if there is an issue decoding the parentID key. This is more for neatness though and to avoid force unwrapping, as I cannot see a reason why this would fail and there are discussions on Swift forums about it too.

enum ParentDecodingError: Error {
    case noParentContextKey
}

do {
    guard let parentIDKey = CodingUserInfoKey.parentIDKey else { throw ParentDecodingError.noParentContextKey }
    let decoder = JSONDecoder()
    decoder.userInfo[parentIDKey] = "abcdefg"
    let data = try decoder.decode(Child.self, from: mockJSONString.data(using: .utf8)!)
    print(data)
} catch {
    print(error.localizedDescription)
}

Reading UserInfo

To read the value during decoding and assign it to parentID, we need to implement a custom init(from decoder:) and extract the value from decoder.userInfo.

struct Child: Decodable {
    let name: String
    let age: Int
    let parentID: String // not in JSON payload
    
    enum CodingKeys: CodingKey {
        case name
        case age
    }
    
    init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        guard let parentIDKey = CodingUserInfoKey.parentIDKey,
        let parentID = decoder.userInfo[parentIDKey] as? String else {
            throw ParentDecodingError.noParentContextKey
        }
        
        self.parentID = parentID
        self.name = try container.decode(String.self, forKey: .name)
        self.age = try container.decode(Int.self, forKey: .age)
    }
}

As you can see this isn't the cleanest thing to do as we need to check for the type's presence in the dictionary, as well as casting it to the correct type, so I would use this very sparingly, as realistically all of this is extra tests you need to write.

Complete Example

So to bring it all together:

import SwiftUI

extension CodingUserInfoKey {
    static let parentIDKey = CodingUserInfoKey(rawValue: "parentIDKey")
}

struct Child: Decodable {
    let name: String
    let age: Int
    let parentID: String // not in JSON payload
    
    enum CodingKeys: CodingKey {
        case name
        case age
    }
    
    init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        guard let parentIDKey = CodingUserInfoKey.parentIDKey,
        let parentID = decoder.userInfo[parentIDKey] as? String else {
            throw ParentDecodingError.noParentContextKey
        }
        
        self.parentID = parentID
        self.name = try container.decode(String.self, forKey: .name)
        self.age = try container.decode(Int.self, forKey: .age)
    }
}

enum ParentDecodingError: Error {
    case noParentContextKey
}

let mockJSONString = """
{
    "name": "Lucy",
    "age": 4
}
"""

do {
    guard let parentIDKey = CodingUserInfoKey.parentIDKey else { throw ParentDecodingError.noParentContextKey }
    let decoder = JSONDecoder()
    decoder.userInfo[parentIDKey] = "abcdefg"
    let data = try decoder.decode(Child.self, from: mockJSONString.data(using: .utf8)!)
    print(data)
} catch {
    print(error.localizedDescription)
}

Running this in the Playground will print:
"Child(name: "Lucy", age: 4, parentID: "abcdefg")"

Final Thoughts

This is a useful feature of JSONDecoder and can help in tricky situations. That said, I consider it a last resort. It makes your Codable implementation messier by requiring custom decoding and introduces external dependencies that can reduce predictability.

Thanks for reading.