top of page

Swift Study Notes

Basic Networking Skills

💡 A skill worth practising without an AI copilot

Every iOS developer should be able to download a small JSON response, inspect its structure, create a matching Decodable model and convert the response into a usable Swift value.

It is a basic exercise, but it is also surprisingly easy to forget when an AI assistant has been writing most of the networking code for us.

Networking is one of the fundamental skills involved in building an iOS application.

Most commercial apps need to communicate with a remote service, download information and transform that information into values the rest of the application can use.

The complete networking layer in a production app may include authentication, retries, caching, request builders, dependency injection, logging and detailed error handling.

However, the basic operation underneath all of that remains quite small:

  1. Create a URL.
  2. Send a request.
  3. Receive some data.
  4. Validate the response.
  5. Decode the JSON into a Swift type.
  6. Use the resulting value.

This is an excellent exercise to practise before an interview.

It tests several ordinary Swift skills at the same time without requiring a large application.

The Exercise

We are going to request launch data from a public SpaceX API endpoint.

The goal is to retrieve information describing a rocket launch and decode part of the JSON response into a Swift structure.

https://api.spacexdata.com/v5/launches/next

For this exercise, we only care about a few fields:

  • The mission name.
  • The launch date.
  • The flight number.
  • Whether the launch is marked as upcoming.

The full JSON response contains many more properties.

We do not need to model all of them.

Important

A Decodable type only needs to describe the values your application intends to decode.

Extra keys in the JSON response can be ignored.

What Is JSON?

JSON stands for JavaScript Object Notation.

It is a text-based format commonly used to exchange structured information between a server and an application.

A simplified launch response might look like this:

{
    "flight_number": 188,
    "name": "USSF-44",
    "date_utc": "2022-11-01T13:41:00.000Z",
    "upcoming": true
}

The response contains four key-value pairs.

Each key is the name supplied by the API.

Each value contains the corresponding piece of information.

Creating a Swift Model

We can represent this JSON using a Swift structure.

struct Launch: Decodable {
    let flightNumber: Int
    let name: String
    let dateUTC: String
    let isUpcoming: Bool
}

The structure conforms to Decodable.

This tells Swift that instances of Launch can be created by decoding an external representation such as JSON.

However, this model does not decode successfully yet.

The names used by the Swift properties do not all match the keys used by the JSON.

JSON Names and Swift Names

The API uses snake case:

"flight_number"
"date_utc"
"upcoming"

Our Swift code uses camel case and a more descriptive property name:

flightNumber
dateUTC
isUpcoming

It is good for our Swift model to use names that feel natural inside Swift.

We should not rename every property merely to imitate the formatting chosen by the API.

Instead, we can explain the mapping using a nested CodingKeys enum.

Using Custom Coding Keys

struct Launch: Decodable {
    let flightNumber: Int
    let name: String
    let dateUTC: String
    let isUpcoming: Bool

    enum CodingKeys: String, CodingKey {
        case flightNumber = "flight_number"
        case name
        case dateUTC = "date_utc"
        case isUpcoming = "upcoming"
    }
}

The CodingKeys enum connects each Swift property to the corresponding JSON key.

For example:

case flightNumber = "flight_number"

This tells the decoder:

When decoding the flightNumber property, retrieve the value stored under the JSON key named flight_number.

The name property does not need a custom raw value because the property and JSON key already have the same name.

case name

CodingKeys Is an Enum

The CodingKeys declaration is another example of Swift using an enum to represent a known set of possibilities.

enum CodingKeys: String, CodingKey {
    case flightNumber = "flight_number"
    case name
    case dateUTC = "date_utc"
    case isUpcoming = "upcoming"
}

Each case represents one property participating in encoding or decoding.

The raw String value represents the key used in the external JSON.

This allows our application model and the server response to use different naming conventions without losing the relationship between them.

Creating the URL

The first executable step is to create a URL.

let url = URL(
    string: "https://api.spacexdata.com/v5/launches/next"
)

The URL(string:) initializer returns an optional because not every String describes a valid URL.

For a hard-coded URL controlled by the application, we might use a guard statement.

guard let url = URL(
    string: "https://api.spacexdata.com/v5/launches/next"
) else {
    throw LaunchError.invalidURL
}

This is already using one of the concepts from our optional article.

The initializer returns an Optional<URL>.

The guard statement extracts the associated URL from .some or throws an error when the optional is .none.

Downloading Data With URLSession

Foundation provides URLSession for performing network requests.

Modern Swift allows us to request data using async and await.

let (data, response) = try await URLSession.shared.data(from: url)

This method returns a tuple containing two values:

  • The downloaded Data.
  • The URL response describing what happened.

The operation can throw because networking can fail.

The device may be offline.

The server may be unavailable.

The connection may time out.

The request may be cancelled.

Why the Function Is Asynchronous

A network request does not complete immediately.

The app must wait for information to travel to a remote server and for the response to return.

We should not block the thread while waiting for this external work.

The await keyword marks a possible suspension point.

let (data, response) = try await URLSession.shared.data(from: url)

The task can suspend while the request is in progress.

When the operation completes, execution resumes with either a result or an error.

This connects naturally with the concurrency topics elsewhere in the 3DaysOfSwift revision plan.

Validating the HTTP Response

Receiving Data does not automatically mean that the request succeeded.

A server can return a response with an error status such as 404 or 500.

We should inspect the HTTP response.

guard let httpResponse = response as? HTTPURLResponse else {
    throw LaunchError.invalidResponse
}

guard (200...299).contains(httpResponse.statusCode) else {
    throw LaunchError.unsuccessfulStatusCode(
        httpResponse.statusCode
    )
}

A status code in the 200 range normally represents a successful HTTP request.

The first guard ensures that we received an HTTP response.

The second guard ensures that its status code is considered successful.

Creating Networking Errors

We can create a small error enum for the failures handled by this exercise.

enum LaunchError: Error {
    case invalidURL
    case invalidResponse
    case unsuccessfulStatusCode(Int)
}

The third case stores the status code as an associated value.

case unsuccessfulStatusCode(Int)

This means the error can preserve useful information about the failure.

throw LaunchError.unsuccessfulStatusCode(500)

Again, we are communicating directly with an enum value.

Decoding the Data

Once we have downloaded and validated the response, we can decode the Data.

let decoder = JSONDecoder()

let launch = try decoder.decode(
    Launch.self,
    from: data
)

The decoder needs two pieces of information:

  • The type we want it to create.
  • The Data containing the JSON.

Launch.self refers to the Launch type itself.

It tells the generic decode method that the expected result is a Launch.

The Decoder Returns a Real Swift Value

Before decoding, we have raw Data.

After decoding, we have a normal instance of our structure.

print(launch.name)
print(launch.flightNumber)
print(launch.dateUTC)
print(launch.isUpcoming)

This is the central transformation performed by the exercise:

Remote JSON
     │
     â–¼
Foundation Data
     │
     â–¼
JSONDecoder
     │
     â–¼
Launch structure

The rest of the application does not need to repeatedly inspect raw JSON.

It can work with a strongly typed Swift model.

The Complete Function

import Foundation

struct Launch: Decodable {
    let flightNumber: Int
    let name: String
    let dateUTC: String
    let isUpcoming: Bool

    enum CodingKeys: String, CodingKey {
        case flightNumber = "flight_number"
        case name
        case dateUTC = "date_utc"
        case isUpcoming = "upcoming"
    }
}

enum LaunchError: Error {
    case invalidURL
    case invalidResponse
    case unsuccessfulStatusCode(Int)
}

func fetchNextLaunch() async throws -> Launch {
    guard let url = URL(
        string: "https://api.spacexdata.com/v5/launches/next"
    ) else {
        throw LaunchError.invalidURL
    }

    let (data, response) = try await URLSession.shared.data(
        from: url
    )

    guard let httpResponse = response as? HTTPURLResponse else {
        throw LaunchError.invalidResponse
    }

    guard (200...299).contains(httpResponse.statusCode) else {
        throw LaunchError.unsuccessfulStatusCode(
            httpResponse.statusCode
        )
    }

    return try JSONDecoder().decode(
        Launch.self,
        from: data
    )
}

This one function demonstrates the essential networking sequence.

  1. Create the endpoint URL.
  2. Download the response.
  3. Validate the HTTP status.
  4. Decode the JSON.
  5. Return the typed model.

Calling the Function

Because fetchNextLaunch() is asynchronous and throwing, the caller must use both try and await.

do {
    let launch = try await fetchNextLaunch()

    print("Mission: \(launch.name)")
    print("Flight: \(launch.flightNumber)")
    print("Date: \(launch.dateUTC)")
} catch {
    print("Unable to download the launch: \(error)")
}

Inside an Xcode Playground or another synchronous context, we can begin an asynchronous task.

Task {
    do {
        let launch = try await fetchNextLaunch()

        print("The next launch is \(launch.name)")
        print("Launch date: \(launch.dateUTC)")
    } catch {
        print("Request failed: \(error)")
    }
}

Decoding the Date as a String

Our first model stores the date as a String.

let dateUTC: String

This is a reasonable starting point because it allows us to concentrate on networking and decoding.

The JSON contains a value similar to:

"date_utc": "2022-11-01T13:41:00.000Z"

However, a commercial application would often prefer to represent a date using Foundation’s Date type.

Decoding Directly Into a Date

We can change the model:

struct Launch: Decodable {
    let flightNumber: Int
    let name: String
    let launchDate: Date
    let isUpcoming: Bool

    enum CodingKeys: String, CodingKey {
        case flightNumber = "flight_number"
        case name
        case launchDate = "date_utc"
        case isUpcoming = "upcoming"
    }
}

We then need to tell JSONDecoder how the date is formatted.

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601

let launch = try decoder.decode(
    Launch.self,
    from: data
)

The model now contains a Date rather than an unprocessed String.

print(launch.launchDate)

A Complete Version Using Date

import Foundation

struct Launch: Decodable {
    let flightNumber: Int
    let name: String
    let launchDate: Date
    let isUpcoming: Bool

    enum CodingKeys: String, CodingKey {
        case flightNumber = "flight_number"
        case name
        case launchDate = "date_utc"
        case isUpcoming = "upcoming"
    }
}

enum LaunchError: Error {
    case invalidURL
    case invalidResponse
    case unsuccessfulStatusCode(Int)
}

func fetchNextLaunch() async throws -> Launch {
    guard let url = URL(
        string: "https://api.spacexdata.com/v5/launches/next"
    ) else {
        throw LaunchError.invalidURL
    }

    let (data, response) = try await URLSession.shared.data(
        from: url
    )

    guard let httpResponse = response as? HTTPURLResponse else {
        throw LaunchError.invalidResponse
    }

    guard (200...299).contains(httpResponse.statusCode) else {
        throw LaunchError.unsuccessfulStatusCode(
            httpResponse.statusCode
        )
    }

    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .iso8601

    return try decoder.decode(
        Launch.self,
        from: data
    )
}

What If the Date Does Not Decode?

Date strings are not always formatted consistently across APIs.

An API may include fractional seconds or use another custom format.

When a date fails to decode, inspect the exact JSON value rather than guessing.

For a learning exercise, storing the value as a String first is often helpful.

Once the basic response decodes successfully, date conversion can be introduced as a separate improvement.

Reading Decoding Errors

JSON decoding can fail for several reasons.

  • A required key is missing.
  • The JSON contains the wrong value type.
  • A custom key has been spelled incorrectly.
  • The response is not valid JSON.
  • The model does not match the structure of the response.

For example, this model expects an Int:

let flightNumber: Int

If the JSON supplies a String instead:

"flight_number": "188"

decoding fails because Swift does not silently pretend that a String is an Int.

This strictness is useful.

It prevents incorrect external data from quietly entering the application under the wrong type.

Printing the Raw JSON While Debugging

When the model does not decode, it can be helpful to inspect the downloaded response.

if let json = String(data: data, encoding: .utf8) {
    print(json)
}

This should usually be a temporary debugging step.

It allows us to compare the real response against the structure we assumed the server would return.

Do not design the model from memory.

Inspect the actual JSON and map the structure that really exists.

Optional JSON Values

APIs frequently return values that may be missing or null.

Suppose the response contains an optional description:

{
    "name": "Example Mission",
    "details": null
}

The corresponding Swift property should be optional.

struct Launch: Decodable {
    let name: String
    let details: String?
}

The decoded optional will be:

  • .some(String) when a description exists.
  • .none when the key is missing or contains null.

This connects directly to the underlying Optional enum discussed elsewhere in the revision notes.

Nested JSON Requires Nested Models

JSON responses often contain nested objects.

{
    "name": "Example Mission",
    "links": {
        "webcast": "https://example.com/watch"
    }
}

We can represent this using another Decodable structure.

struct Launch: Decodable {
    let name: String
    let links: LaunchLinks
}

struct LaunchLinks: Decodable {
    let webcast: URL?
}

The shape of the Swift model follows the shape of the JSON.

Launch
    │
    ├── name
    │
    └── links
            │
            └── webcast

Do Not Begin With a Networking Manager

In a commercial codebase, networking would rarely remain as one free function placed beside a view.

We would normally communicate with a dedicated type such as:

  • NetworkingManager
  • APIManager
  • APIClient
  • LaunchService

However, introducing that architecture too early can hide the skill we are trying to practise.

Before creating protocols, dependency containers and abstractions, make sure you can still perform the basic operation yourself.

Download JSON and decode it into a struct.

That is the exercise.

Moving the Code Into an API Client

Once the basic function is understood, we can place the responsibility inside a dedicated type.

final class APIManager {

    func fetchNextLaunch() async throws -> Launch {
        guard let url = URL(
            string: "https://api.spacexdata.com/v5/launches/next"
        ) else {
            throw LaunchError.invalidURL
        }

        let (data, response) = try await URLSession.shared.data(
            from: url
        )

        guard let httpResponse = response as? HTTPURLResponse else {
            throw LaunchError.invalidResponse
        }

        guard (200...299).contains(httpResponse.statusCode) else {
            throw LaunchError.unsuccessfulStatusCode(
                httpResponse.statusCode
            )
        }

        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601

        return try decoder.decode(
            Launch.self,
            from: data
        )
    }
}

The caller now communicates with the APIManager.

let apiManager = APIManager()
let launch = try await apiManager.fetchNextLaunch()

The networking steps have not disappeared.

They have simply been moved behind a type responsible for communicating with the API.

A More Focused Launch Service

A commercial application may prefer a service named after the part of the API it represents.

final class LaunchService {

    func fetchNextLaunch() async throws -> Launch {
        // Perform request and decoding
    }
}

This can be clearer than placing every endpoint into one increasingly large APIManager.

The application might eventually contain:

LaunchService
RocketService
CrewService
LaunchpadService

Each type handles a related collection of requests.

That is an architectural decision for the commercial codebase.

It does not change the core networking skill beneath it.

Separating Networking From the View

A SwiftUI view should not need to understand HTTP status codes, JSONDecoder configuration and endpoint construction.

The view should request data from a model or service and describe how the current state should appear.

import Observation
import SwiftUI

@Observable
@MainActor
final class LaunchModel {
    var launch: Launch?
    var errorMessage: String?
    var isLoading = false

    private let apiManager = APIManager()

    func loadNextLaunch() async {
        isLoading = true
        defer { isLoading = false }

        do {
            launch = try await apiManager.fetchNextLaunch()
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

The SwiftUI view can then observe the model.

struct NextLaunchView: View {
    let model: LaunchModel

    var body: some View {
        VStack {
            if model.isLoading {
                ProgressView()
            } else if let launch = model.launch {
                Text(launch.name)
                Text(launch.launchDate.formatted())
            } else if let errorMessage = model.errorMessage {
                Text(errorMessage)
            }
        }
        .task {
            await model.loadNextLaunch()
        }
    }
}

The model coordinates the operation.

The API manager performs the request.

The Launch structure represents the decoded data.

The view describes how each possible state should appear.

The Networking Pipeline

SwiftUI View
      │
      â–¼
Observable Model
      │
      â–¼
API Manager
      │
      â–¼
URLSession
      │
      â–¼
Remote JSON
      │
      â–¼
JSONDecoder
      │
      â–¼
Launch Structure
      │
      â–¼
Observable Model
      │
      â–¼
Updated SwiftUI View

This is a small version of a pattern used throughout commercial iOS development.

Why Practise This Without AI?

AI coding tools can create an entire networking layer in seconds.

That can be useful.

However, repeatedly delegating the work can weaken our ability to explain and debug it.

An interview may not ask us to build a perfect production networking architecture.

It may ask us to perform a much smaller exercise:

Here is an endpoint and an example JSON response.

Download it and decode it into a Swift type.

That is a reasonable expectation for an iOS developer.

It reveals whether we understand:

  • URL creation.
  • Asynchronous functions.
  • Throwing functions.
  • URLSession.
  • HTTP responses.
  • Data.
  • Decodable.
  • JSONDecoder.
  • CodingKeys.
  • Optionals.
  • Error handling.

AI Can Hide Small Gaps in Memory

A developer may understand networking very well but still forget the exact method signature after months of allowing an AI assistant to produce it.

let (data, response) = try await URLSession.shared.data(
    from: url
)

That does not mean the developer has lost their engineering ability.

It means the small act of recalling and assembling the code has not been practised recently.

This is exactly why short revision exercises are valuable.

We are not attempting to memorise an entire framework.

We are keeping an important pathway familiar.

Build the First Version From Memory

Try completing the exercise without copying the finished implementation.

Begin with an empty Playground or project and attempt to remember the sequence:

Create URL
Download Data
Validate Response
Create Decodable Model
Configure Decoder
Decode Data
Handle Error
Print Result

When you become stuck, look up only the missing step.

Then close the reference and write the complete exercise again.

This develops recall without pretending that professional developers never consult documentation.

Common Interview Mistakes

Forgetting That URL Creation Can Fail

let url = URL(string: endpoint)

This produces an optional URL.

The optional must be handled unless the input has been proven in another way.

Ignoring the HTTP Response

let (data, _) = try await URLSession.shared.data(from: url)

This may be acceptable for an extremely small demonstration, but a complete answer should usually validate the status code.

Decoding the Wrong Shape

If the JSON root is an array, the decoder must decode an array.

let launches = try decoder.decode(
    [Launch].self,
    from: data
)

If the root is one object, decode one object.

let launch = try decoder.decode(
    Launch.self,
    from: data
)

Assuming Property Names Automatically Match

JSON keys and Swift properties may use different names.

Use CodingKeys or an appropriate decoder strategy.

Using try? Too Early

let launch = try? decoder.decode(
    Launch.self,
    from: data
)

This converts every decoding error into an optional .none.

That can hide the reason the response failed to decode.

While developing the model, allow the real error to remain visible.

Updating UI State From the Wrong Isolation Context

Observable UI state should normally be isolated to the main actor.

@MainActor
@Observable
final class LaunchModel {
    var launch: Launch?
}

The networking request can suspend without blocking the main thread.

Once it completes, the main-actor model can safely update the state observed by the interface.

Using convertFromSnakeCase

JSONDecoder can automatically convert many snake-case keys into camel case.

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

This can map:

"flight_number"

to:

flightNumber

However, it cannot infer every semantic rename.

For example, it will not automatically know that:

"upcoming"

should become:

isUpcoming

A custom CodingKeys entry is still required for that relationship.

enum CodingKeys: String, CodingKey {
    case flightNumber = "flight_number"
    case name
    case launchDate = "date_utc"
    case isUpcoming = "upcoming"
}

Custom Keys Are Worth Practising

A perfectly matching model is easier to decode.

However, interview exercises often deliberately include a property whose Swift name differs from its JSON key.

This tests whether the developer understands that Decodable is not magic.

The decoder needs a mapping between the external representation and the Swift model.

Networking Is More Than Downloading

A production networking layer may also need to handle:

  • HTTP methods.
  • Request headers.
  • Authentication tokens.
  • Request bodies.
  • Pagination.
  • Rate limiting.
  • Retries.
  • Caching.
  • Cancellation.
  • Offline behaviour.
  • Server error payloads.
  • Logging and observability.

Those are important topics.

They should not prevent us from first mastering the smaller operation at the centre of the system.

The Basic Skill

Given an endpoint and a sample JSON response, create a matching Decodable model, download the Data and decode it into that model.

If you can perform and explain that sequence comfortably, you have retained the foundation upon which a larger networking layer can be built.

A Small Practice Challenge

Before looking at the solution, create a function with this signature:

func fetchNextLaunch() async throws -> Launch

Your solution should:

  1. Create the SpaceX endpoint URL.
  2. Download the response using URLSession.
  3. Check for a successful status code.
  4. Decode the response into Launch.
  5. Map at least one JSON key using CodingKeys.
  6. Return the decoded model.

Once it works, add one improvement at a time.

  • Decode the date into Date.
  • Add a nested Links model.
  • Decode the webcast URL.
  • Display the result in SwiftUI.
  • Move the request into an APIManager.
  • Inject the APIManager into an observable model.

Interview Questions

What is URLSession?

URLSession is a Foundation API used to perform network requests and related data-transfer operations.

What does data(from:) return?

It asynchronously returns a tuple containing the downloaded Data and a URLResponse.

Why should the HTTP status code be checked?

A request can successfully receive a response even when the server reports an error such as 404 or 500.

What is Decodable?

Decodable is a protocol describing a type that can initialise itself from an external encoded representation.

What does JSONDecoder do?

JSONDecoder reads JSON Data and attempts to create an instance of the requested Decodable type.

What is Launch.self?

It refers to the Launch type itself and tells the generic decode method which result type to create.

What are CodingKeys?

CodingKeys is a nested enum used to map properties in a Codable type to keys in an encoded representation.

Do we need to model every property returned by an API?

No.

A Decodable model can include only the properties required by the application.

What happens when the JSON contains additional keys?

JSONDecoder ignores keys that are not represented by the model.

What happens when a required property is missing?

Decoding normally fails unless the property is optional or custom decoding provides another behaviour.

Why might a JSON property be represented as optional?

The API may omit the key or return null when no value is available.

Why use a custom CodingKey?

A custom CodingKey connects a Swift property to a JSON key with a different name.

Why not put the complete request directly inside a SwiftUI view?

Separating networking from the view keeps the interface focused on presentation and makes the request logic easier to test, reuse and maintain.

Why practise this exercise without AI?

Writing the steps yourself maintains your ability to recall, explain and debug the fundamental networking process during an interview or production incident.

Final Revision Note

Networking architecture can become sophisticated, but the foundation remains simple.

Create a URL.

Download Data using URLSession.

Validate the response.

Create a Decodable structure that matches the JSON you need.

Use CodingKeys when the server’s property names differ from the names you want in Swift.

Decode the response into a strongly typed model.

In commercial code, this work will usually live inside an APIManager, NetworkingManager, APIClient or focused service.

However, every iOS developer should remain capable of performing the underlying exercise without depending entirely on generated code.

Remember

Download the JSON.

Inspect its real structure.

Map it to a Decodable model.

Handle the failure paths.

The architecture may change, but this basic networking skill remains.

bottom of page