Section 4 · Shipping a Real App
Networking with URLSession and Codable
17 min3 min readPut in order
Lesson at a glance
Fetch JSON with URLSession’s async API, check the HTTP status, decode it into Codable structs, and show loading, loaded and failed states in SwiftUI.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Pocket Budget records expenses in dollars, but you spend money on holiday in euros and Egyptian pounds. To convert, the app needs today’s exchange rates from a server. This lesson replaces the pretend Task.sleep from the last lesson with a real request: build a URL, fetch the data, check the response, decode the JSON into Swift types and show it. Those steps are the same for nearly every API you will ever call.
The examples use a stand-in endpoint, https://api.example.com/v1/latest?base=USD. Swap in a real exchange-rate provider (most have a free tier) and adjust the struct to match its JSON. The response looks like this:
{
"base": "USD",
"updated_at": "2026-10-02T06:00:00Z",
"rates": { "EUR": 0.92, "GBP": 0.79, "EGP": 48.31 }
}Mirror the JSON with Codable
import Foundation
struct RatesResponse: Decodable {
let base: String
let updatedAt: Date
let rates: [String: Decimal]
}Decodable asks the compiler to generate the code that reads this type from an external format. Property names must match the JSON keys and the types must fit: a JSON object becomes a dictionary, a number can decode into Decimal, and a missing key or wrong type throws a DecodingError that names the problem. Use Codable (Decodable plus Encodable) when you also send the type back. The struct is made of Sendable parts, so it’s Sendable too and can travel safely from the network code to the main actor.
The JSON says updated_at; Swift style says updatedAt. Rather than renaming properties, configure the decoder, as the client below does.
Fetch, check, decode
enum RateError: Error {
case badStatus(Int)
}
struct RateClient {
let endpoint = URL(string: "https://api.example.com/v1/latest")!
func latest(base: String) async throws -> RatesResponse {
let url = endpoint.appending(queryItems: [URLQueryItem(name: "base", value: base)])
let (data, response) = try await URLSession.shared.data(from: url)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
guard status == 200 else {
throw RateError.badStatus(status)
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(RatesResponse.self, from: data)
}
}Walk through it in order. appending(queryItems:) builds ...latest?base=USD and percent-encodes values for you, which is safer than gluing strings together. The force unwrap on URL(string:) is the acceptable kind from the optionals lesson: a literal you wrote, where nil would mean a typo.
URLSession.shared.data(from:) is the async API: it suspends until the whole response has arrived and returns the body bytes plus a URLResponse. It throws for transport failures, such as no connection, a timeout or cancellation, but it does not throw for HTTP errors. That’s what the guard is for.
.convertFromSnakeCase maps updated_at to updatedAt for every key, and .iso8601 reads the timestamp format most APIs use. Then decode either returns a fully formed RatesResponse or throws.
Not every failure deserves the same message, either. A thrown URLError carries a code, so you can tell “offline” (.notConnectedToInternet) apart from “timed out” (.timedOut) and say “You’re offline. Rates will refresh when you reconnect” instead of a generic apology. Users forgive an app that explains itself.
Loading states the view can trust
The enum idea from the enums lesson now pays off. A model on the main actor owns the state, and the view switches over it:
@MainActor
@Observable
final class RatesModel {
enum LoadState {
case idle
case loading
case loaded(RatesResponse)
case failed(String)
}
var state: LoadState = .idle
private let client = RateClient()
func load() async {
state = .loading
do {
let response = try await client.latest(base: "USD")
state = .loaded(response)
} catch {
state = .failed(error.localizedDescription)
}
}
}struct RatesView: View {
@State private var model = RatesModel()
var body: some View {
List {
switch model.state {
case .idle, .loading:
ProgressView()
case .loaded(let response):
ForEach(response.rates.keys.sorted(), id: \.self) { code in
LabeledContent(code, value: response.rates[code] ?? 0,
format: .number.precision(.fractionLength(2...4)))
}
case .failed(let message):
ContentUnavailableView("Couldn't load rates",
systemImage: "wifi.exclamationmark",
description: Text(message))
}
}
.navigationTitle("Exchange Rates")
.task { await model.load() }
.refreshable { await model.load() }
}
}.task loads once when the screen appears, and .refreshable adds pull-to-refresh with the same function. Because RatesModel is @MainActor, assigning state is always safe for the UI, and Swift 6 checks that for you. Every error path ends in .failed, so the user never stares at a spinner that will never stop. Dictionary keys have no order, so the view sorts them for a stable list.
iOS also requires HTTPS by default through App Transport Security, so a plain http:// URL fails unless you add an exception. Use HTTPS and you’ll never need one.
Pocket Budget can now talk to the outside world. It still forgets its own expenses when you quit, so next you’ll save them on the device with SwiftData.
Key takeaways
try await URLSession.shared.data(from: url)returns the body and the response; it throws for network failures but not for HTTP errors like 404 or 500.- Always check the
HTTPURLResponsestatus code before decoding, and turn a bad status into your own error. - Mirror the JSON with
Decodablestructs;keyDecodingStrategyanddateDecodingStrategyhandle snake_case keys and ISO 8601 dates. - Model the screen’s lifecycle as a
LoadStateenum so loading, data and errors can never be shown at the same time.