Most apps are windows onto data that lives somewhere else. Swift's Codable protocol and async/await concurrency make fetching and decoding that data concise and type-safe, with no third-party libraries needed.
The JSON we want to decode
{
"order_id": 1042,
"customer_name": "Maria Rossi",
"created_at": "2026-09-28T09:15:00Z",
"total": 38.5,
"items": [
{ "sku": "FW-01", "name": "Flat white", "qty": 2 },
{ "sku": "CR-02", "name": "Almond croissant", "qty": 1 }
]
}Model it with Codable
import Foundation
struct Order: Codable, Identifiable {
let orderId: Int
let customerName: String
let createdAt: Date
let total: Decimal
let items: [Item]
var id: Int { orderId }
struct Item: Codable, Hashable {
let sku: String
let name: String
let qty: Int
}
}Properties use Swift naming; the decoder converts snake_case keys for you. Decimal avoids the rounding errors that Double can introduce with money.
Configure the decoder
let decoder: JSONDecoder = {
let d = JSONDecoder()
d.keyDecodingStrategy = .convertFromSnakeCase
d.dateDecodingStrategy = .iso8601
return d
}()Custom keys when names do not match
struct Product: Codable {
let id: String
let displayName: String
let price: Decimal
enum CodingKeys: String, CodingKey {
case id = "sku"
case displayName = "title"
case price
}
}Fetch with async/await
enum APIError: Error {
case badStatus(Int)
}
struct OrdersClient {
let baseURL = URL(string: "https://api.example.com")!
func recentOrders() async throws -> [Order] {
let url = baseURL.appending(path: "orders/recent")
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse else { return [] }
guard (200..<300).contains(http.statusCode) else {
throw APIError.badStatus(http.statusCode)
}
return try decoder.decode([Order].self, from: data)
}
}Show it in SwiftUI
import SwiftUI
struct OrdersView: View {
@State private var orders: [Order] = []
@State private var errorMessage: String?
var body: some View {
NavigationStack {
List(orders) { order in
VStack(alignment: .leading) {
Text(order.customerName).font(.headline)
Text(order.total, format: .currency(code: "GBP"))
.foregroundStyle(.secondary)
}
}
.navigationTitle("Recent orders")
.overlay {
if let errorMessage {
ContentUnavailableView("Could not load orders",
systemImage: "wifi.exclamationmark",
description: Text(errorMessage))
}
}
.task { await load() }
.refreshable { await load() }
}
}
private func load() async {
do {
orders = try await OrdersClient().recentOrders()
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
}.task starts the request when the view appears and cancels it automatically if the view disappears. .refreshable adds pull-to-refresh for free.
Debugging decoding errors
When decoding fails, print the full error. DecodingError tells you exactly which key or type did not match.
do {
let order = try decoder.decode(Order.self, from: data)
print(order)
} catch let DecodingError.keyNotFound(key, context) {
print("Missing key:", key.stringValue, "at", context.codingPath.map(\.stringValue))
} catch let DecodingError.typeMismatch(type, context) {
print("Type mismatch for", type, "at", context.codingPath.map(\.stringValue))
} catch {
print(error)
}Keep the Swift cheat sheet nearby for syntax reminders, and see what is new in Swift 6.2 for the latest concurrency changes.
Written by Alessandro Ecclesie Agazzi, freelance analytics engineer in London. Updated 30 September 2026.