Parsing JSON with Swift
Table of Contents
Swift’s Codable APIs make JSON parsing approachable, but real-world payloads often include missing keys, dates, snake_case fields, or constrained enums that deserve stronger typing. This guide captures the patterns I reach for when building production decoders, so future-me has a single reference of best practices and battle-tested examples.
Sample JSON Payload#
{
"id": 42,
"username": "entangled_dev",
"display_name": "Entangled Dev",
"created_at": "2025-10-01T09:42:00Z",
"is_admin": false,
"profile": {
"bio": "Building with Swift and SwiftUI.",
"website": "https://entangled.dev"
},
"favorite_languages": ["Swift", "Rust", "TypeScript"]
}
Create a Codable Model#
struct User: Codable {
let id: Int
let username: String
let displayName: String
let createdAt: Date
let isAdmin: Bool
let profile: Profile
let favoriteLanguages: [String]
struct Profile: Codable {
let bio: String
let website: URL
}
enum CodingKeys: String, CodingKey {
case id
case username
case displayName = "display_name"
case createdAt = "created_at"
case isAdmin = "is_admin"
case profile
case favoriteLanguages = "favorite_languages"
}
}
CodingKeys let you align Swift’s camelCase with the snake_case keys from the backend, keeping the rest of the code clean.
Decode with JSONDecoder#
Assume sampleJSONString is the raw JSON string you received from a network call or fixture.
let jsonData = sampleJSONString.data(using: .utf8)!
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
do {
let user = try decoder.decode(User.self, from: jsonData)
print("Loaded user:", user.username)
} catch {
print("Failed to decode user:", error.localizedDescription)
}
convertFromSnakeCase handles most key conversions automatically. Define explicit CodingKeys when the mapping is more nuanced or when you want control over a single property.
Configure a Shared Decoder#
For larger projects, configure JSONDecoder once so the rest of your codebase stays focused on domain logic.
enum Decoders {
static func api() -> JSONDecoder {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
decoder.dataDecodingStrategy = .base64
decoder.nonConformingFloatDecodingStrategy = .throw
return decoder
}
}
Returning a fresh decoder keeps the instance stateless and thread-safe. Keeping the configuration in one place makes it obvious which strategies are in play. If the API delivers milliseconds-since-epoch dates tomorrow, you only have one place to update.
extension JSONDecoder.DateDecodingStrategy {
static let millisecondsSince1970: JSONDecoder.DateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let milliseconds = try container.decode(Double.self)
return Date(timeIntervalSince1970: milliseconds / 1000)
}
}
Swap this strategy in whenever the service moves away from ISO 8601 timestamps.
Handling Optional Data#
Use optionals for fields the API might omit and supply sensible defaults.
{
"id": "F2A0D5C2-3E43-4E58-880E-6BA16AD669E8",
"title": "Build SwiftUI Layouts Quickly",
"read_time_minutes": 6
}
struct Article: Codable {
let id: UUID
let title: String
let subtitle: String?
let readTimeMinutes: Int
enum CodingKeys: String, CodingKey {
case id
case title
case subtitle
case readTimeMinutes = "read_time_minutes"
}
}
let decoder = Decoders.api()
let articleJSON = """
{
"id": "F2A0D5C2-3E43-4E58-880E-6BA16AD669E8",
"title": "Build SwiftUI Layouts Quickly",
"read_time_minutes": 6
}
"""
let data = Data(articleJSON.utf8)
let article = try decoder.decode(Article.self, from: data)
print(article.subtitle ?? "No subtitle provided")
When a key is missing and the property is non-optional, decoding fails. Treat optional values as a contract with the API, annotating properties only when absence is valid.
Supplying Defaults Without Optionals#
Sometimes the backend omits a value but you still want a non-optional property. Provide the default inside a custom initializer so callers always receive a fully-formed model.
{
"enabled": false
}
struct NotificationSettings: Codable {
let isEnabled: Bool
let frequency: Frequency
enum CodingKeys: String, CodingKey {
case isEnabled = "enabled"
case frequency
}
enum Frequency: String, Codable {
case immediate, hourly, daily
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
frequency = try container.decodeIfPresent(Frequency.self, forKey: .frequency) ?? .daily
}
}
let settingsJSON = """
{
"enabled": false
}
"""
let settingsData = Data(settingsJSON.utf8)
let settings = try Decoders.api().decode(NotificationSettings.self, from: settingsData)
print(settings.frequency) // daily
decoder.container(keyedBy:) hands you a keyed view into the JSON object so you can look up multiple fields by CodingKey. Think of it as opening a dictionary-like sub-decoder scoped to the current object. Without grabbing a container, you could only decode a single value at a time, which is fine for simple payloads but breaks down when you need to read several properties and coordinate defaults in one initializer.
Once you have a container, you reach into it with decode(_:forKey:) and decodeIfPresent(_:forKey:). They belong to the container—not JSONDecoder—and are the workhorses for extracting values.
decode(_:forKey:)assumes the value exists and is well-formed, so it either returns the decoded value or throws. Use it when the API contract says “this key is always there.”decodeIfPresent(_:forKey:)returnsnilwhen the key is missing or explicitlynull, letting you fall back to sensible defaults without leaking optionals throughout the call site.
JSONDecoder will automatically assign nil to optional properties when you rely on the synthesized initializer, but only if the property itself is declared optional. In this example frequency is non-optional because the rest of the app expects a real value. Using decodeIfPresent lets you detect a missing key, supply a fallback (.daily), and still keep the property non-optional. If you called decode(_:forKey:) and the key or value was missing, decoding would throw instead of letting you inject a default.
What Happens to init(from:)#
You never call init(from:) directly. When you ask JSONDecoder to decode a type, it checks whether that type implements init(from:). If it does, the decoder instantiates the Decoder context (containing the key/value containers) and invokes your initializer, passing that context in. If your type has no custom initializer, the compiler synthesizes one automatically. All you do is call decode(_:from:); the Decoder plumbing happens under the hood.
Here is what Decoders.api().decode(NotificationSettings.self, from: settingsData) does behind the scenes:
decodebuilds aDecoderobject with the raw bytes and your global strategies.- The decoder sees
NotificationSettingsconforms toDecodable, so it calls yourinit(from:)(or the synthesized one if you didn’t write one). - Inside
init(from:), you pull data out of the keyed container and provide defaults. - When the initializer returns,
JSONDecoderhands you the fully initialized instance.
So the initializer is “injected,” but only because it’s mandated by the Decodable protocol; the runtime simply honors the implementation you supplied.
Modeling Enumerated Values#
APIs often send constrained values that map cleanly to Swift enums. Prefer strongly typed enums over raw String or Int fields so you get compiler guidance and exhaustiveness checking.
1. Direct String Backing#
{
"path": "https://cdn.entangled.dev/images/header.png",
"type": "png"
}
struct RemoteImage: Codable {
let path: URL
let type: ImageType
enum ImageType: String, Codable {
case png
case jpg
case heic
}
}
Because ImageType conforms to String and Codable, decoding fails fast when the backend introduces an unexpected value, helping you spot contract changes. When a payload truly allows free-form values, fall back to String or Int; otherwise, rely on enums to keep the domain explicit and safe.
2. Mapping Backend Slugs to Expressive Cases#
Sometimes the backend uses values that do not align with Swift naming conventions. When the payload is still a single string, mirror CodingKeys by assigning the raw value directly:
{
"kind": "profile-picture"
}
enum AvatarKind: String, Codable {
case profilePicture = "profile-picture"
case organizationLogo = "organization-logo"
case systemGenerated = "system-generated"
}
For richer enums that need custom logic during decoding or encoding, map them explicitly while keeping expressive case names.
{
"path": "https://cdn.entangled.dev/avatars/42.png",
"kind": "profile-picture"
}
struct Avatar: Codable {
let path: URL
let kind: Kind
enum Kind: Codable {
case profilePicture
case organizationLogo
case systemGenerated
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
switch try container.decode(String.self) {
case "profile-picture": self = .profilePicture
case "organization-logo": self = .organizationLogo
case "system-generated": self = .systemGenerated
default: throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported avatar kind")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .profilePicture: try container.encode("profile-picture")
case .organizationLogo: try container.encode("organization-logo")
case .systemGenerated: try container.encode("system-generated")
}
}
}
}
decoder.singleValueContainer() is what you reach for when the JSON fragment you’re decoding is literally just a single primitive (a string, number, or bool) instead of an object with keys. Without it, you’d be forced to introduce fake CodingKeys or wrap the value in another type just to read it. The single-value container hands you that raw value directly so you can convert it into an enum case (or any other type) in one step. encoder.singleValueContainer() mirrors the idea when writing JSON, ensuring the enum encodes back to the original one-value payload.
Why does this work when the parent JSON (Avatar) also has a path key? JSONDecoder handles the outer object first. It builds a keyed container for Avatar, decodes path, and when it reaches kind it delegates to Kind.init(from:). That delegate initializer receives a nested decoder that already points at the "kind" value, so singleValueContainer() sees only that scalar string—"profile-picture"—instead of the whole object. Containers are scoped, so they never “see” sibling keys; the keyed container for Avatar keeps walking properties one-by-one.
switch try container.decode(String.self) then decodes the underlying string (throwing if it is missing or malformed) and immediately pattern-matches on the result. Each case maps the backend slug to the right Swift case, while the default branch surfaces unsupported values. This syntax keeps the decode + mapping logic tight and ensures the initializer exits once it finds a match.
func encode(to encoder: Encoder) is the flip side of init(from:). Conforming to Codable implicitly means conforming to both Decodable and Encodable; implementing this method lets you control how the enum (or struct) writes back to JSON when you call JSONEncoder().encode(...).
3. Capturing Unknown Cases#
If you need to keep the app resilient while still flagging surprises, add an unknown case that stashes the raw value for logging.
enum ImageType: Codable {
case png
case jpg
case heic
case unknown(String)
init(from decoder: Decoder) throws {
let value = try decoder.singleValueContainer().decode(String.self)
switch value {
case "png": self = .png
case "jpg": self = .jpg
case "heic": self = .heic
default: self = .unknown(value)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .png: try container.encode("png")
case .jpg: try container.encode("jpg")
case .heic: try container.encode("heic")
case .unknown(let raw): try container.encode(raw)
}
}
}
This approach guards you against backends that silently add new types while preserving diagnostics for analytics or crash logs. You can combine it with the explicit mapping technique above by routing the default branch to .unknown(value) instead of throwing.
Custom Decoding Logic#
For more complex transformations, implement init(from:).
{
"accent_color": "FF0080"
}
struct Color: Codable {
let red: Double
let green: Double
let blue: Double
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let hex = try container.decode(String.self)
guard let rgb = Int(hex, radix: 16) else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Expected a hex string like FF0080"
)
}
red = Double((rgb >> 16) & 0xFF) / 255
green = Double((rgb >> 8) & 0xFF) / 255
blue = Double(rgb & 0xFF) / 255
}
}
let colorJSON = """
{
"accent_color": "FF0080"
}
"""
let accent = try Decoders.api().decode([String: Color].self, from: Data(colorJSON.utf8))
print(accent["accent_color"]?.red ?? 0)
Here the payload is a hex string ("FF0080") and the decoder must convert it into normalized color components. Throwing a DecodingError produces actionable diagnostics during development.
Decoding Polymorphic Payloads#
When APIs send unions (one of several shapes under the same key), model them with enums that have associated values.
{
"attachments": [
{ "type": "image", "url": "https://cdn.entangled.dev/image.png", "width": 640, "height": 480 },
{ "type": "file", "name": "report.pdf", "size_bytes": 10240 },
{ "type": "code", "language": "swift", "snippet": "print(\"Hello\")" }
]
}
enum Attachment: Codable {
case image(Image)
case file(File)
case code(Code)
struct Image: Codable {
let url: URL
let width: Int
let height: Int
}
struct File: Codable {
let name: String
let sizeBytes: Int
}
struct Code: Codable {
let language: String
let snippet: String
}
private enum CodingKeys: String, CodingKey {
case type
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "image":
self = .image(try Image(from: decoder))
case "file":
self = .file(try File(from: decoder))
case "code":
self = .code(try Code(from: decoder))
default:
throw DecodingError.dataCorruptedError(
forKey: .type,
in: container,
debugDescription: "Unsupported attachment type: \(type)"
)
}
}
}
Using enums keeps the call site type-safe. A switch on Attachment forces you to handle every variant and lets you opt in to unknown-case strategies where appropriate.
Mapping Nested Collections#
When the API wraps arrays under a key, decode them via nested containers.
{
"data": {
"articles": [
{ "id": "1", "title": "Swift Strings 101", "read_time_minutes": 4 },
{ "id": "2", "title": "Macros in Swift 5.9", "read_time_minutes": 7 }
]
}
}
struct FeedResponse: Decodable {
let articles: [Article]
enum CodingKeys: String, CodingKey {
case data
}
enum DataKeys: String, CodingKey {
case articles
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let dataContainer = try container.nestedContainer(keyedBy: DataKeys.self, forKey: .data)
articles = try dataContainer.decode([Article].self, forKey: .articles)
}
}
Nested containers keep your models minimal while handling payload structures that mirror database responses or GraphQL-style edges.
If the nested payload carries multiple fields you care about, decode each sibling from the same container.
{
"data": {
"articles": [
{ "id": "1", "title": "Swift Strings 101", "read_time_minutes": 4 },
{ "id": "2", "title": "Macros in Swift 5.9", "read_time_minutes": 7 }
],
"other_property": "some value",
"featured_article": { "id": "3", "title": "Concurrency Deep Dive", "read_time_minutes": 12 }
}
}
struct RichFeedResponse: Decodable {
let articles: [Article]
let featured: Article?
let otherProperty: String
enum CodingKeys: String, CodingKey { case data }
enum DataKeys: String, CodingKey {
case articles
case featuredArticle = "featured_article"
case otherProperty = "other_property"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let dataContainer = try container.nestedContainer(keyedBy: DataKeys.self, forKey: .data)
articles = try dataContainer.decode([Article].self, forKey: .articles)
featured = try dataContainer.decodeIfPresent(Article.self, forKey: .featuredArticle)
otherProperty = try dataContainer.decode(String.self, forKey: .otherProperty)
}
}
Nested containers behave like scoped dictionaries; once you have one, read every relevant key—required or optional—before you leave it.
Integrating with Legacy [String: Any] Stores#
Some codebases funnel network payloads through a store that already parses JSON into [String: Any]. You can still lean on your Codable models instead of hand-casting dictionaries.
let storePayload: [String: Any] = [
"data": [
"articles": [
["id": "1", "title": "Swift Strings 101", "read_time_minutes": 4],
["id": "2", "title": "Macros in Swift 5.9", "read_time_minutes": 7]
],
"other_property": "some value"
]
]
func decodeFeed(from payload: [String: Any]) throws -> RichFeedResponse {
guard JSONSerialization.isValidJSONObject(payload) else {
throw NSError(domain: "Store", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON object"])
}
let data = try JSONSerialization.data(withJSONObject: payload)
return try Decoders.api().decode(RichFeedResponse.self, from: data)
}
Converting back to Data lets you reuse the same decoders and validation logic while keeping the legacy store untouched. If you truly need to stay in dictionary space, encapsulate the nested lookups in a helper so the casting is localized.
func articles(from payload: [String: Any]) throws -> [[String: Any]] {
guard
let data = payload["data"] as? [String: Any],
let articles = data["articles"] as? [[String: Any]]
else {
throw NSError(domain: "Store", code: 1, userInfo: [NSLocalizedDescriptionKey: "Articles missing"])
}
return articles
}
The dictionary approach works, but it is fragile: missing keys collapse to nil, spelling mistakes slip through, and you duplicate parsing logic everywhere you need the data. Wrapping that plumbing in a helper at least centralizes the risk, but re-encoding to Data and letting Codable handle validation buys you type safety, better diagnostics, and parity with the rest of the codebase. Start with the helper as a stopgap, then migrate callers to the Codable path.
articles ends up as [[String: Any]] because every element of the JSON articles array is a JSON object ({ ... }). When bridged to Swift without a model, each object becomes a [String: Any] dictionary containing the article’s fields ("id", "title", "read_time_minutes", and so on). The surrounding array just preserves the list of those dictionaries until you map them into strong types.
Surface Decoding Errors Clearly#
DecodingError carries rich context. Pattern-match on it to log actionable diagnostics instead of generic “data corrupted” messages.
func log(_ error: Error) {
switch error {
case DecodingError.keyNotFound(let key, let context):
print("Missing key \(key.stringValue):", context.debugDescription)
case DecodingError.typeMismatch(let type, let context):
print("Type mismatch for \(type):", context.debugDescription)
case DecodingError.valueNotFound(let type, let context):
print("Expected value for \(type):", context.debugDescription)
case DecodingError.dataCorrupted(let context):
print("Data corrupted:", context.debugDescription)
default:
print("Unexpected error:", error.localizedDescription)
}
}
Attach decoded context, request identifiers, or user IDs to this logging to make server/client contracts easier to debug.
Decoding with Swift Concurrency#
func loadUser(from url: URL) async throws -> User {
let (data, _) = try await URLSession.shared.data(from: url)
return try Decoders.api().decode(User.self, from: data)
}
By returning the decoded type directly, your async functions stay composable. Propagating thrown errors lets the caller decide whether to retry, show an alert, or load fallback data. Pass the decoder in as an argument when you want to override strategies (e.g., JSON with UNIX timestamps).
Testing Your Decoders#
Decode against local fixtures in unit tests to assert that the models match the API contract.
func testUserDecoding() throws {
let bundle = Bundle.module
let url = try XCTUnwrap(bundle.url(forResource: "user_fixture", withExtension: "json"))
let data = try Data(contentsOf: url)
let decoder = Decoders.api()
let user = try decoder.decode(User.self, from: data)
XCTAssertEqual(user.favoriteLanguages.count, 3)
XCTAssertEqual(user.profile.website.host, "entangled.dev")
}
Testing with captured payloads surfaces breaking changes early and documents assumptions about optional versus required fields.
Wrap Up#
Swift’s Codable ecosystem covers the majority of JSON parsing scenarios: adopt Decodable models, configure JSONDecoder centrally, lean on enums for constrained values, and reach for custom initializers when you need defaults or polymorphism. Combine these patterns with targeted logging and fixture-based tests to keep API integrations resilient and easy to reason about.