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

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: true

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.

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)

📋 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() }

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:

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

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.