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
Decodablemodel 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:
- Create a URL.
- Send a request.
- Receive some data.
- Validate the response.
- Decode the JSON into a Swift type.
- 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
Decodabletype 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
flightNumberproperty, retrieve the value stored under the JSON key namedflight_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.
- Create the endpoint URL.
- Download the response.
- Validate the HTTP status.
- Decode the JSON.
- 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..nonewhen 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:
NetworkingManagerAPIManagerAPIClientLaunchService
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:
- Create the SpaceX endpoint URL.
- Download the response using URLSession.
- Check for a successful status code.
- Decode the response into Launch.
- Map at least one JSON key using CodingKeys.
- 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.
