Lesson 7 • Intermediate (Final Lesson)
Working with APIs 🌐
By the end of this lesson you'll fetch real data from the internet with URLSession, turn JSON into Swift types with Codable, handle failures cleanly with do/try/catch, and show the result in a SwiftUI view — the skill that turns a static app into a live one.
What You'll Learn in This Lesson
- Build a URL and fetch data with URLSession
- Decode JSON into Codable structs with JSONDecoder
- Use modern async/await instead of callbacks
- Handle errors safely with do / try / catch
- Check HTTP status codes and unwrap the URL safely
- Load and display API data in SwiftUI with .task
List. The Codable examples run anywhere (paste them into SwiftFiddle); the live-network and SwiftUI snippets need an Xcode project to run.1️⃣ Codable — Turning JSON Into Swift Types
APIs speak JSON — plain text like { "id": 1, "title": "Hello" }. To use it, you describe its shape with a model (a struct) and mark it Codable. That one word lets JSONDecoder read the text and fill in your struct automatically — every property name must match a JSON key, with the matching type. Read this worked example and run it; it needs no network.
import Foundation
// A "model" is a Swift type that mirrors the shape of the JSON.
// Conforming to Codable lets Swift map JSON <-> this struct for free.
struct Post: Codable {
let id: Int
let title: String
let body: String
}
// This is the exact JSON the API would send back (a single post).
let jsonString = """
{
"id": 1,
"title": "Hello, Swift",
"body": "Networking is easier than it looks."
}
"""
// 1. Turn the text into Data (the raw bytes a server returns).
let data = Data(jsonString.utf8)
// 2. JSONDecoder reads the bytes and fills in a Post for you.
// 'Post.self' tells the decoder which type to build.
let post = try JSONDecoder().decode(Post.self, from: data)
// 3. Now it's a normal Swift value — use it like any struct.
print("Post #\(post.id): \(post.title)") // Post #1: Hello, Swift
print(post.body) // Networking is easier than it looks.Post #1: Hello, Swift
Networking is easier than it looks.2️⃣ URLSession + async/await — Fetching Over the Network
URLSession is Swift's built-in networking tool. The modern way to use it is async/await: you write try await URLSession.shared.data(from: url) and the line pauses until the bytes arrive, then carries on — reading top-to-bottom like ordinary code, with no nested callbacks. URL(string:) returns an Optional, so unwrap it safely, and remember URLSession only throws for transport errors — you check the HTTP status yourself.
import Foundation
struct Post: Codable {
let id: Int
let title: String
let body: String
}
// A custom error type makes failures readable and easy to 'catch'.
enum APIError: Error {
case badStatus(Int)
}
// 'async' = this function can pause; 'throws' = it can fail.
// '-> [Post]' = on success it hands back an array of Post.
func fetchPosts() async throws -> [Post] {
// 'URL(string:)' returns an Optional, so unwrap it safely.
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
throw APIError.badStatus(0)
}
// 'await' suspends here until the bytes arrive — no callbacks, no freezing.
let (data, response) = try await URLSession.shared.data(from: url)
// URLSession does NOT throw on 404/500, so check the status yourself.
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? -1)
}
// Decode the JSON array straight into [Post].
return try JSONDecoder().decode([Post].self, from: data)
}
// Call it from an async context and handle errors with do/catch.
Task {
do {
let posts = try await fetchPosts()
print("Fetched \(posts.count) posts") // Fetched 100 posts
print("First: \(posts[0].title)") // First: sunt aut facere ...
} catch {
print("Request failed: \(error)")
}
}Fetched 100 posts
First: sunt aut facere repellat provident occaecatiYour turn. Below, the JSON and the calls are written for you — fill in the four ___ blanks marked with // 👉 to make the model and the decode work.
import Foundation
// 🎯 YOUR TURN — fill in each ___ then run it (try it on SwiftFiddle).
// The API sends this JSON for one user:
let jsonString = """
{ "id": 7, "name": "Ada Lovelace", "isActive": true }
"""
// 1) Make the model match the JSON. It must conform to Codable
// and have a property for each key, with the matching type.
struct User: ___ { // 👉 the protocol that enables JSON mapping
let id: Int
let name: String
let isActive: ___ // 👉 the Swift type for true/false
}
let data = Data(jsonString.utf8)
// 2) Decode the bytes into a User. Pass the TYPE, not an instance.
let user = try JSONDecoder().decode(___, from: data) // 👉 User.self
print("\(user.name) — active: \(user.isActive)")
// ✅ Expected output:
// Ada Lovelace — active: trueAda Lovelace — active: true3️⃣ Error Handling — do / try / catch
Networking fails all the time — no signal, a typo in a URL, a server hiccup. Swift makes that explicit: a function that can fail is marked throws, you call it with try, and you wrap the call in do { … } catch { … } so a failure becomes a message instead of a crash. Inside catch, the thing that went wrong is available as error.
Now you try. This function is missing the three concurrency/error keywords. Fill in the three ___ blanks so it suspends, fetches, and handles failure.
import Foundation
struct Quote: Codable { let text: String }
func fetchQuote() ___ throws -> Quote { // 👉 the keyword that allows 'await'
guard let url = URL(string: "https://example.com/quote") else {
throw URLError(.badURL)
}
// 1) Suspend until the data arrives. URLSession.shared.data(from:)
// is an async throwing call, so it needs TWO keywords here.
let (data, _) = try ___ URLSession.shared.data(from: url) // 👉 the suspend keyword
// 2) Decode the JSON into a Quote.
return try JSONDecoder().decode(Quote.self, from: data)
}
Task {
do {
let quote = try await fetchQuote()
print(quote.text)
} catch {
// 3) Print the error so a failed request never crashes the app.
print("Could not load quote: \(___)") // 👉 the value caught by 'catch'
}
}
// ✅ Expected (on success): the quote text prints.
// On failure: "Could not load quote: ..." prints instead of crashing.async, await, and error for it to compile inside Xcode.4️⃣ Using the Data in SwiftUI with .task
A view that shows live data stores it in @State and loads it when it appears. The .task { … } modifier runs async code as the view shows up and cancels it automatically if the view disappears — so prefer it over .onAppear for loading. Code inside .task already runs on the main actor, so assigning to @State there updates the UI safely.
import SwiftUI
struct Post: Codable, Identifiable {
let id: Int
let title: String
}
struct PostListView: View {
// @State holds data that, when changed, redraws the view.
@State private var posts: [Post] = []
@State private var errorMessage: String?
var body: some View {
List {
if let errorMessage {
Text(errorMessage).foregroundStyle(.red)
}
ForEach(posts) { post in
Text(post.title)
}
}
// '.task' runs async code when the view appears and
// auto-cancels it if the view disappears — perfect for loading.
.task {
do {
posts = try await fetchPosts()
} catch {
// UI updates from .task are already on the main actor — safe here.
errorMessage = "Failed to load: \(error.localizedDescription)"
}
}
}
}fetchPosts() function from section 2. Deep Dive: parallel requests with async let
Sequential await calls wait for each other. When requests are independent, async let starts them all at once so the screen loads faster:
// All three start immediately, then we await the results.
async let user = fetchUser(id: 1)
async let posts = fetchPosts()
async let stats = fetchStats()
let dashboard = try await Dashboard(user: user, posts: posts, stats: stats)For snake_case APIs, skip hand-written CodingKeys with one line: decoder.keyDecodingStrategy = .convertFromSnakeCase maps first_name to firstName for the whole response.
Common Errors (and the fix)
- "Errors thrown from here are not handled" / "'async' call in a function that does not support concurrency": you forgot
tryorawait. A networking call needs both:try await URLSession.shared.data(from: url), inside a function markedasync throws(or aTask { }). - "keyNotFound" / "typeMismatch" when decoding: your struct doesn't match the JSON. Fix the property name/type, make a missing field Optional with
?, or add CodingKeys to map a different JSON key (e.g.case userId = "user_id"). - "Publishing changes from background threads is not allowed" / purple runtime warning: you updated the UI off the main thread. Update
@Stateinside.task(already main-actor), or wrap the UI work inawait MainActor.run { … }. - App crashes with "Unexpectedly found nil" on the URL: you force-unwrapped
URL(string: "…")!on a malformed string. Unwrap safely withguard let url = URL(string: …) else { throw … }instead. - Request "succeeds" but you got an error page: URLSession doesn't throw on 404/500. Cast to
HTTPURLResponseand checkstatusCode == 200, throwing your own error otherwise.
📋 Quick Reference — Networking & JSON
| Task | Swift Code |
|---|---|
| Build a URL safely | guard let url = URL(string: s) else { ... } |
| GET request | try await URLSession.shared.data(from: url) |
| Decode JSON | JSONDecoder().decode(T.self, from: data) |
| Encode JSON | JSONEncoder().encode(value) |
| snake_case keys | decoder.keyDecodingStrategy = .convertFromSnakeCase |
| Check status | (response as? HTTPURLResponse)?.statusCode |
| Handle errors | do { try await ... } catch { ... } |
| Load in SwiftUI | .task { await loadData() } |
Frequently Asked Questions
Q: Why do I have to write try, await, AND throws?
They do different jobs. 'throws' on a function declaration means it can fail; 'try' marks the exact call that might throw so it's visible; and 'await' marks where the function pauses to wait for an async result. A networking call is both asynchronous and failable, so you typically write 'try await someCall()' inside a function declared 'async throws'.
Q: My JSON uses snake_case keys like first_name but my Swift uses camelCase. How do I decode it?
You have two options. The quick one: set decoder.keyDecodingStrategy = .convertFromSnakeCase before decoding, and Swift maps first_name to firstName automatically. The explicit one: add a CodingKeys enum to your struct that maps each property to its JSON key, e.g. case firstName = "first_name". CodingKeys also lets you rename or skip individual fields.
Q: Why does my decode fail with 'keyNotFound' or 'typeMismatch'?
Your model doesn't match the JSON. 'keyNotFound' means a property in your struct has no matching JSON key (check spelling and use CodingKeys, or make the property Optional with '?'). 'typeMismatch' means the types differ — e.g. the JSON has "42" (a string) but your property is an Int. Make the struct mirror the real response exactly.
Q: URLSession didn't throw on a 404 — is that a bug?
No, that's by design. A 404 or 500 is a perfectly valid HTTP response, so URLSession only throws for transport problems (no internet, bad URL, timeout). To treat a 404 as an error, cast the response to HTTPURLResponse and check statusCode == 200 yourself, throwing your own error otherwise.
Q: Do I have to switch to the main thread to update the UI?
Yes — UIKit and SwiftUI must be updated on the main actor. The good news: code inside a SwiftUI '.task { }' already runs on the main actor, so assigning to @State there is safe. If you do background work and need to touch the UI, mark the function or type @MainActor, or await MainActor.run { ... }.
Q: What's the difference between sequential await and async let?
Sequential 'try await' calls run one after another — the second waits for the first to finish. 'async let' starts several requests at once and lets them run in parallel; you 'await' the results later. Use async let when the requests don't depend on each other to make your screen load noticeably faster.
Mini-Challenge: Fetch a To-Do
No blanks this time — just a brief and an outline. Put together everything from this lesson: a Codable model, an async throws fetch, and a do/catch. Build it in Xcode and check your output against the expected line in the comments.
import Foundation
// 🎯 MINI-CHALLENGE: fetch and print a to-do item
// The endpoint https://jsonplaceholder.typicode.com/todos/1 returns:
// { "userId": 1, "id": 1, "title": "delectus aut autem", "completed": false }
//
// 1. Make a Codable struct "Todo" with: userId (Int), id (Int),
// title (String), completed (Bool).
// 2. Write func fetchTodo() async throws -> Todo that builds the URL,
// awaits URLSession.shared.data(from:), and decodes a Todo.
// 3. In a Task { } call it with try await, inside a do/catch.
// 4. Print: "Todo: <title> (done: <completed>)"
//
// ✅ Expected output:
// Todo: delectus aut autem (done: false)
// your code here🎉 Course Complete — You Did It!
This was the final Swift lesson. Here's what you can now do:
- ✅ Model JSON with
Codablestructs and decode it withJSONDecoder - ✅ Fetch data over the network with
URLSessionand async/await - ✅ Build URLs safely and check HTTP status codes
- ✅ Handle failures with
do / try / catchand custom error types - ✅ Show live data in SwiftUI with
@Stateand.task
Where to go next: wire this into a real app idea. Persist data with SwiftData or Core Data, add pull-to-refresh and loading states, structure logic into an MVVM view model, then publish to TestFlight. Practise by building a weather, news, or GitHub-search app against a free public API — you now have every piece you need.
Sign up for free to track which lessons you've completed and get learning reminders.