Skip to main content
    Courses/Swift/Working with APIs

    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

    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.

    Worked example: decode a JSON response with Codable
    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.
    Output
    Post #1: Hello, Swift
    Networking is easier than it looks.
    This is real code — run it for free atSwiftFiddleor in your own editor.

    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.

    Worked example: fetch + decode with async/await
    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)")
        }
    }
    Output
    Fetched 100 posts
    First: sunt aut facere repellat provident occaecati
    This makes a live network call, so build it in an Xcode project (or Swift package). The output shows what the real JSONPlaceholder endpoint returns.

    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.

    🎯 Your turn: build a model and decode it
    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: true
    Output
    Ada Lovelace — active: true
    Fill in the blanks, then run it on SwiftFiddle and check it prints the expected line.

    3️⃣ 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.

    🎯 Your turn: add async, await, and the caught error
    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.
    Fill in the three blanks. You can't run this against a live URL on SwiftFiddle, but the keywords must be 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.

    Worked example: load posts into a SwiftUI List
    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)"
                }
            }
        }
    }
    This is a real SwiftUI view — build it in an Xcode project alongside the 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 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

    TaskSwift Code
    Build a URL safelyguard let url = URL(string: s) else { ... }
    GET requesttry await URLSession.shared.data(from: url)
    Decode JSONJSONDecoder().decode(T.self, from: data)
    Encode JSONJSONEncoder().encode(value)
    snake_case keysdecoder.keyDecodingStrategy = .convertFromSnakeCase
    Check status(response as? HTTPURLResponse)?.statusCode
    Handle errorsdo { 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.

    🎯 Mini-Challenge: fetch and print a to-do item
    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
    Write it yourself, then run it in an Xcode project against the real JSONPlaceholder endpoint.

    🎉 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.

    Sign up for free to track which lessons you've completed and get learning reminders.

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service