Advanced Usage of Decodable in Swift: Handling Dynamic Keys
Use DynamicKey to safely decode JSON with unpredictable keys — it avoids fragile if let chains and makes your decoding logic flexible and maintainable.
Join the DZone community and get the full member experience.
Join For FreeWhen your backend sends responses that don't follow a consistent structure, Swift's Decodable system can begin to reveal its limitations. It expects structure. Predictability. Stability. However, real-world APIs — especially those powering social feeds, content backends, or any CMS-driven application — rarely fit that mold.
This article takes a look under the hood of Swift's decoding system. The goal isn't to memorize recipes, but to understand what's really happening so you can build decoding logic that scales with the unpredictable nature of your APIs.
What Decodable Does
Decodable is Swift's bridge between untyped data and strongly-typed models. When you call JSONDecoder().decode, Swift walks your type's structure, looking for property names that match JSON keys as defined in your CodingKeys enum.
That's perfect when the shape of your JSON is known at compile time. But if the server starts swapping key names depending on content type — "article" today, "wiki" tomorrow — your decoding logic breaks.
The Problem: Dynamic Server Keys
Let's look at a feed response that changes shape depending on what the server sends:
{
"article": {
"title": "Exploring Swift Concurrency",
"itemTypeName": "blogArticle",
"author": "Jane Doe"
}
}
Then, in another case:
{
"wiki": {
"title": "Internal Guidelines",
"itemTypeName": "wiki",
"editor": "John Smith"
}
}
And sometimes, you get:
{
"message": "Server will go down for maintenance at midnight."
}
Each top-level key (article, wiki, message) signals a different content type. You can't predict this key at compile time, so a static CodingKeys enum won't help.
Handling Dynamic Keys
Brute Force: Conditional Decoding
A first attempt might look like this:
let container = try decoder.container(keyedBy: CodingKeys.self)
if let article = try? container.decode(SharedArticle.self, forKey: .article) {
self = .article(article: article)
} else if let wiki = try? container.decode(SharedArticle.self, forKey: .wiki) {
self = .wiki(article: wiki)
} else if let message = try? container.decode(String.self, forKey: .message) {
self = .post(message: message)
} else {
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Unknown key"))
}
It works — until it doesn’t. Every new key type means another conditional branch. Maintenance hell.
A Better Way: The DynamicKey Pattern
To handle unknown keys gracefully, define a reusable CodingKey that can represent any key:
public struct DynamicKey: CodingKey {
public var intValue: Int? { nil }
public var stringValue: String
public init?(intValue: Int) { nil }
public init?(stringValue: String) { self.stringValue = stringValue }
}
Now, you can write a single initializer that decodes dynamically:
public enum TimelinePostContent: Decodable {
case post(message: String)
case article(article: SharedArticle)
case wiki(article: SharedArticle)
case event(event: SharedEvent)
case page(page: SharedPage)
case workspace(workspace: SharedWorkspace)
case blocks(blocks: [SharedBlock])
private enum CodingKeys: String, CodingKey {
case message, article, event, page, workspace, blocks
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicKey.self)
for key in container.allKeys {
switch CodingKeys(rawValue: key.stringValue) {
case .message:
self = .post(message: try container.decode(String.self, forKey: key))
return
case .article:
let article = try container.decode(SharedArticle.self, forKey: key)
switch article.itemTypeName {
case .wiki:
self = .wiki(article: article)
case .blogArticle:
self = .article(article: article)
default:
throw DecodingError.dataCorruptedError(forKey: key, in: container, debugDescription: "Unexpected article type")
}
return
case .event:
self = .event(event: try container.decode(SharedEvent.self, forKey: key))
return
case .page:
self = .page(page: try container.decode(SharedPage.self, forKey: key))
return
case .workspace:
self = .workspace(workspace: try container.decode(SharedWorkspace.self, forKey: key))
return
case .blocks:
self = .blocks(blocks: try container.decode([SharedBlock].self, forKey: key))
return
default:
continue
}
}
throw DecodingError.dataCorruptedError(forKey: DynamicKey(stringValue: "data")!, in: container, debugDescription: "Failed to parse data container")
}
}
No hardcoded keys in CodingKeys. No repetitive if let jungle. You’re using runtime inspection while keeping type safety intact.
Why the DynamicKey Pattern Wins
Dynamic decoding isn't about being clever — it's about control. When your app interacts with APIs that change over time or vary by content type, you need a strategy that strikes a balance between resilience and clarity. The DynamicKey approach delivers both.
It Handles Unpredictable Data Gracefully
With DynamicKey, you're no longer hardcoding assumptions about what the server will send. Instead, you inspect what's actually there at runtime. That means fewer decoding crashes when the backend team adds a new type or wraps payloads differently. The code adapts without breaking — and when something unexpected appears, it fails in a predictable, debuggable way.
It Keeps You Out of the “Conditional Jungle”
If you've ever maintained a long list of if let article = try? ... else if let wiki = try? ..., you know how fragile it gets. Each new branch duplicates logic, and every edit invites subtle errors. The dynamic approach replaces that tangle with one tight loop that reads the keys and matches them to your known cases. You're not rewriting decoding logic — you're describing the data's intent once and extending it over time.
It Scales Without Losing Type Safety
A standard fallback for unpredictable JSON is to decode into [String: Any] or rely on manual parsing with JSONSerialization. That's flexible, but you pay for it in safety and maintainability. DynamicKey gives you the same flexibility without giving up static typing — your enums and structs remain checked by the compiler. It's dynamic where it needs to be and strongly typed everywhere else.
It Makes Change Manageable
When new content types arrive, you don't rewrite your decoding logic. You add one case, one decoding branch, and the system continues to function. This makes it easier to evolve your model layer without touching every endpoint or rewriting half your parsing logic. Over time, that predictability matters — especially in larger codebases or teams where multiple developers handle different features.
It Encourages Explicitness Over Magic
This isn't reflection, introspection, or some metaprogramming trick. The flow is explicit, visible, and debuggable. If decoding fails, you know exactly which key caused it and why. That transparency makes onboarding easier and debugging faster — two things you'll appreciate when you're diagnosing issues at 2 AM.
Trade-Offs Worth Understanding
- No compile-time key validation: Swift won't warn you if the backend changes a key name. You'll catch it in tests, not builds.
- More upfront code: The initializer is longer than a static one, but it's still centralized and predictable.
- Testing becomes essential: Because flexibility means more possible paths, you need a solid suite of decoding tests that cover real and edge-case payloads.
In short, DynamicKey is a pragmatic pattern for addressing the complex aspects of networked data. It doesn't hide complexity — it gives you a structure to manage it.
Summary
- Use CodingKeys when your schema is stable.
- Use a DynamicKey helper when your API isn’t.
- Keep decoding logic explicit — clarity beats cleverness.
The DynamicKey pattern is one of those small shifts that changes how you think about decoding: from rigid mappings to flexible, data-driven parsing. It's not a trick — it's an understanding of how Swift's decoding machinery works and how to make it serve the complex, dynamic world of real APIs.
Opinions expressed by DZone contributors are their own.
Comments