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.
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.
- ContentUnavailableView
- A standard empty or error state, so failures look intentional.
.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)
}Check your understanding
1/3Which decoder setting turns customer_name into customerName?
CodingKeys is only needed when names differ in other ways.
2/3Why use Decimal instead of Double for prices?
0.1 + 0.2 is not exactly 0.3 in Double.
3/3What does SwiftUI's .task modifier do when the view disappears?
Structured concurrency ties the task's lifetime to the view.
You got 0 out of 3.
Keep the Swift cheat sheet nearby for syntax reminders, and see what is new in Swift 6.4 for the latest language changes.
