Working with APIs
Reviewed & published by Brayan K
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.
Part of the free Swift course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
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
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.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)")
}
}Your 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: 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.
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)"
}
}
}
}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:
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 try or await. A networking call needs both: try await URLSession.shared.data(from: url), inside a function marked async throws (or a Task { }).
- "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 @State inside .task (already main-actor), or wrap the UI work in await MainActor.run { … }.
- App crashes with "Unexpectedly found nil" on the URL: you force-unwrapped URL(string: "…")! on a malformed string. Unwrap safely with guard let url = URL(string: …) else { throw … } instead.
- Request "succeeds" but you got an error page: URLSession doesn't throw on 404/500. Cast to HTTPURLResponse and check statusCode == 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() } |
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 Codable structs and decode it with JSONDecoder
- ✅ Fetch data over the network with URLSession and async/await
- ✅ Build URLs safely and check HTTP status codes
- ✅ Handle failures with do / try / catch and custom error types
- ✅ Show live data in SwiftUI with @State and .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.
Practice quiz
Which protocol lets a struct map to and from JSON?
- Codable
- Serializable
- JSONMappable
- Decoder
Answer: Codable. Conforming to Codable enables automatic JSON encoding and decoding.
Which type reads JSON bytes into a Swift value?
- JSONReader
- JSONDecoder
- JSONParser
- Decoder.shared
Answer: JSONDecoder. JSONDecoder().decode(...) turns Data into your model.
Which keyword marks a function that can pause for async work?
- throws
- defer
- async
- lazy
Answer: async. async marks a function that can suspend while awaiting results.
What does await do in a networking call?
- Throws an error
- Declares a constant
- Retries the request
- Suspends until the result arrives
Answer: Suspends until the result arrives. await pauses the function until the async result is ready.
Does URLSession throw an error on a 404 response?
- No, you must check the status code yourself
- Yes, always
- Only on 500
- Only without internet
Answer: No, you must check the status code yourself. URLSession only throws for transport errors; check statusCode == 200 yourself.
Which construct handles a thrown error safely?
- if let / else
- do / try / catch
- guard / return
- switch / default
Answer: do / try / catch. do { try ... } catch { ... } catches errors instead of crashing.
What does URL(string:) return?
- A String
- A Data value
- An Optional URL
- An error
Answer: An Optional URL. URL(string:) is failable and returns an optional, so unwrap it safely.
Which keyword marks a call inside catch-able code that may fail?
- catch
- async
- await
- try
Answer: try. try marks the exact call that may throw an error.
Which SwiftUI modifier runs async loading when a view appears?
- .task
- .onTap
- .refreshable
- .async
Answer: .task. .task runs async code on appear and auto-cancels on disappear.
What do you pass to decode to name the target type?
- Post()
- Post
- Post.self
- "Post"
Answer: Post.self. Pass the type itself, e.g. Post.self, to JSONDecoder().decode.
Continue this course
- Previous: SwiftUI Basics
- Next: Optionals & nil Safety — Swift's signature feature — handle missing values safely with ?, if let and ??
Frequently asked questions
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'.
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.
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.
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.
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 { ... }.
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.