This guide is a fast, practical reference for using Codable with JSON in real apps, including a clean separation between network DTOs and domain models. It’s written with modern Swift (Swift 5.9+ / Swift 6) in mind.
protocol Codable = Encodable & DecodableIf all stored properties in a type are Codable, the compiler can usually synthesize encoding and decoding for you automatically.
struct UserDTO: Codable {
let id: Int
let fullName: String
let email: String?
}- Mirrors the JSON payload from the network or disk.
- Naming and shape are close to the API.
- Lives in a “Networking” / “API” module.
- Is almost always
Codable.
struct UserDTO: Codable {
let id: Int
let fullName: String
let email: String?
let createdAt: Date
}- Reflects your business logic.
- Designed for how the app wants to reason about data.
- Isolated from API quirks and changes.
struct User {
struct ID: Hashable, Sendable {
let rawValue: Int
}
let id: ID
let name: String
let email: EmailAddress?
let createdAt: Date
let isStaff: Bool
}extension User {
init(dto: UserDTO) {
self.id = .init(rawValue: dto.id)
self.name = dto.fullName
self.email = dto.email.map(EmailAddress.init(rawValue:))
self.createdAt = dto.createdAt
self.isStaff = dto.email?.hasSuffix("@company.com") == true
}
}Rule of thumb:
- DTOs are
Codableand tightly bound to the API. - Domain models are free to evolve; they may or may not be
Codable.
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
let user = try decoder.decode(UserDTO.self, from: data)let users = try decoder.decode([UserDTO].self, from: data)let dto = UserDTO(id: 1, fullName: "Jonathan", email: nil, createdAt: .now)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(dto)
let jsonString = String(data: data, encoding: .utf8)Use when server keys don’t match your Swift names.
struct UserDTO: Codable {
let id: Int
let fullName: String
let email: String?
let createdAt: Date
enum CodingKeys: String, CodingKey {
case id
case fullName = "full_name"
case email
case createdAt = "created_at"
}
}struct ProfileDTO: Codable {
let id: Int // required: decoding fails if missing
let bio: String? // optional: nil if missing or null
let avatarURL: URL? // optional
}- Use optionals for fields that may be missing or
null. - Use non-optionals for fields that must be present.
Pattern: use decodeIfPresent and provide defaults.
struct SettingsDTO: Codable {
let theme: String
let notificationsEnabled: Bool
enum CodingKeys: String, CodingKey {
case theme
case notificationsEnabled = "notifications_enabled"
}
init(theme: String = "light", notificationsEnabled: Bool = true) {
self.theme = theme
self.notificationsEnabled = notificationsEnabled
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let theme = try container.decodeIfPresent(String.self, forKey: .theme) ?? "light"
let notificationsEnabled = try container.decodeIfPresent(Bool.self, forKey: .notificationsEnabled) ?? true
self.init(theme: theme, notificationsEnabled: notificationsEnabled)
}
}Key APIs:
decode(_:forKey:)→ throws if key is missing or value is invalid.decodeIfPresent(_:forKey:)→ returnsnilwhen key is missing ornull.
JSON:
{
"id": 1,
"profile": {
"bio": "Hello",
"avatar_url": "https://example.com/avatar.png"
}
}DTO:
struct UserDTO: Codable {
struct Profile: Codable {
let bio: String
let avatarURL: URL
enum CodingKeys: String, CodingKey {
case bio
case avatarURL = "avatar_url"
}
}
let id: Int
let profile: Profile
}Top-level array:
let users = try decoder.decode([UserDTO].self, from: data)Top-level dictionary:
let lookup = try decoder.decode([String: UserDTO].self, from: data)Always be explicit about date formats.
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
// Or .secondsSince1970, .millisecondsSince1970, .formatted(DateFormatter)Align with your API team on formats and mirror in JSONEncoder.
do {
let user = try JSONDecoder().decode(UserDTO.self, from: data)
print(user)
} catch let error as DecodingError {
switch error {
case .keyNotFound(let key, let context):
print("Missing key: \(key.stringValue) in \(context.codingPath)")
case .typeMismatch(let type, let context):
print("Type mismatch for \(type) in \(context.codingPath): \(context.debugDescription)")
case .valueNotFound(let type, let context):
print("Value not found for \(type) in \(context.codingPath): \(context.debugDescription)")
case .dataCorrupted(let context):
print("Data corrupted: \(context.debugDescription)")
@unknown default:
print("Unknown decoding error: \(error)")
}
} catch {
print("Other error: \(error)")
}In debug builds, also log the raw JSON when safe.
Typical layering:
APIClient- fetches
DatawithURLSession+ async/await - decodes
DTOs
- fetches
Repository- converts
DTOs to domain models - applies business rules, caching, fallback
- converts
- UI / View Models
- talk only to repositories, never directly to DTOs or
URLSession.
- talk only to repositories, never directly to DTOs or
This naturally composes with Swift Concurrency and the Observation framework: your observable view models consume domain models, not network DTOs.
Portions of drafting and editorial refinement in this repository were accelerated using large language models (including ChatGPT, Claude, and Gemini) under direct human design, validation, and final approval. All technical decisions, code, and architectural conclusions are authored and verified by the repository maintainer.