Overview
Real weather APIs never guarantee every field will be present — a sensor might not report humidity, a "feels like" figure might be omitted in mild conditions, and an alerts list only shows up when there is actually something to warn about. That is precisely the shape `Codable` is built for: a `WeatherResponse` struct where the genuinely optional fields are typed as Optionals, so Swift's `JSONDecoder` automatically treats a missing JSON key as `nil` for those fields, with no manual key-checking required.
By the end of this tutorial you will have a console application that decodes simulated JSON weather payloads into a `Codable` struct with `JSONDecoder`, wrapped in a `do`/`catch` block so a malformed payload is reported rather than silently ignored, and a closure-based formatter that safely unwraps every optional field before ever printing it — no force-unwraps, and no field that could crash the program if the data behind it happens to be absent.
- A `WeatherResponse: Codable` struct matching a realistic simulated JSON payload.
- Optional fields (`feelsLike`, `humidity`, `alerts`) that decode to `nil` when the JSON omits them.
- A dictionary of simulated raw JSON strings standing in for a real weather API.
- A `fetchWeather(for:)` function using `JSONDecoder().decode(...)` inside a `do`/`catch`.
- A closure-based `formatWeather` value that unwraps every optional with `map` and `??` before display.
- A `readLine()`-driven lookup loop tying it all together.
Prerequisites
- Structs — stored properties and `Codable` conformance basics.
- Optionals — `Optional<T>` fields, `if let`, `map` on an Optional, and nil-coalescing with `??`.
- Closures — declaring a closure with an explicit type and calling it like a function.
- Error handling — `do`/`catch` and the `try` keyword around a throwing call.
- Dictionaries — `[String: String]` and subscript lookup returning an Optional.
Project Structure
The program lives in a single file, `main.swift`. `WeatherResponse` is the `Codable` model matching the shape of the (simulated) API payload. `simulatedResponses` is a `[String: String]` dictionary standing in for a real network call — a genuine app would replace it with a `URLSession.shared.dataTask` request, but the decoding, error handling, and formatting logic below would not need to change at all, since all of it only depends on already having a `Data` value to decode. `fetchWeather(for:)` turns a city name into an Optional `WeatherResponse`, and `formatWeather` is a standalone closure responsible for turning that struct into safe, human-readable text.
Step 1: Define the WeatherResponse Codable Struct
Every stored property here is itself `Codable` — `String`, `Double`, `Int`, `[String]`, and Optionals of `Codable` types are all automatically `Codable` — so Swift can synthesize the entire encoding and decoding implementation with no manual `CodingKeys` or `init(from:)` needed. `feelsLike`, `humidity`, and `alerts` are Optionals specifically because a real weather provider does not always include them in every response.
// Codable is a compiler-synthesized conformance: because every stored// property below is itself Codable (String, Double, Int, [String], and// Optionals of Codable types), Swift generates the JSON encoding/decoding// logic automatically — no manual keys or init(from:) needed.struct WeatherResponse: Codable { let city: String let temperature: Double let condition: String let feelsLike: Double? // Optional: some providers omit "feels like" entirely for certain conditions let humidity: Int? // Optional: not every sensor reports humidity let alerts: [String]? // Optional array: omitted entirely from the payload when there are no active alerts}Step 2: Simulate a JSON API Response
A real app would fetch this data over the network; here the payloads are inlined as Swift multi-line strings so the project can be run and understood with no network access or API key required. Notice that Delhi's payload omits `feelsLike` and `alerts` entirely, and Bengaluru's omits `humidity` and `alerts` — `JSONDecoder` handles a missing key for an Optional property by decoding it as `nil` automatically, with no extra configuration needed.
// A real app would fetch this over the network with URLSession; here the// payloads are inlined as Swift multi-line strings so the project can be run// and understood without any network access or API key.let simulatedResponses: [String: String] = [ "mumbai": """ {"city": "Mumbai", "temperature": 31.5, "condition": "Humid", "feelsLike": 36.0, "humidity": 78, "alerts": ["Heavy rain expected"]} """, "delhi": """ {"city": "Delhi", "temperature": 24.0, "condition": "Clear", "humidity": 40} """, "bengaluru": """ {"city": "Bengaluru", "temperature": 22.5, "condition": "Cloudy", "feelsLike": 22.0} """]Step 3: Decode JSON With JSONDecoder and do/catch
`decoder.decode(WeatherResponse.self, from:)` is a throwing function — it can fail if the JSON does not actually match `WeatherResponse`'s shape — so the call is wrapped in `try` inside a `do`/`catch` block rather than using `try?`, specifically so a genuine parsing failure can be logged with `catch`'s `error` value instead of silently collapsing into the same `nil` an unknown city would also produce.
func fetchWeather(for city: String) -> WeatherResponse? { guard let json = simulatedResponses[city.lowercased()] else { return nil // Unknown city: nothing to decode at all } guard let data = json.data(using: .utf8) else { return nil // Defensive: only fails if the string somehow isn't valid UTF-8 } do { let decoder = JSONDecoder() return try decoder.decode(WeatherResponse.self, from: data) // Throws if the JSON doesn't match WeatherResponse } catch { // A real API could send malformed data; surface the failure instead of // silently treating a bad payload the same as an unknown city. print("Failed to parse weather data for \(city): \(error)") return nil }}Click Run to see what this code prints.
Step 4: Build a Closure-Based Formatter That Unwraps Every Optional
`formatWeather` is stored in a `let` as a closure value with an explicit `(WeatherResponse) -> String` type, not written as a free function — that makes the formatting strategy itself a value that could later be swapped for a different one (a metric-vs-imperial formatter, for instance) without touching the code that calls it. Inside it, `Optional.map` runs its closure only when there is a real value and leaves `nil` as `nil`, and the trailing `??` supplies the fallback text for that `nil` case — together they unwrap every optional field with no force-unwraps anywhere in the function.
// A closure stored in a let, not a free function — this makes the// formatting strategy a value that could be swapped out (e.g. a metric vs.// imperial formatter) without touching the code that calls it.let formatWeather: (WeatherResponse) -> String = { weather in var lines: [String] = [] lines.append("Weather for \(weather.city): \(weather.condition), \(weather.temperature)°C")
// map on an Optional runs the closure only if there is a value, leaving // nil as nil — then ?? supplies the fallback text for the nil case, so // every branch is handled without a single force-unwrap anywhere. let feelsLikeText = weather.feelsLike.map { "\($0)°C" } ?? "not reported" lines.append("Feels like: \(feelsLikeText)")
let humidityText = weather.humidity.map { "\($0)%" } ?? "not reported" lines.append("Humidity: \(humidityText)")
if let alerts = weather.alerts, !alerts.isEmpty { lines.append("Alerts: \(alerts.joined(separator: ", "))") } else { lines.append("Alerts: none") }
return lines.joined(separator: "\n")}Step 5: Build the Lookup Loop
The loop is deliberately simple: read a city name, check for the `"exit"` sentinel, and otherwise hand the input to `fetchWeather(for:)`. If that returns a real `WeatherResponse`, `formatWeather` from Step 4 renders it; if it returns `nil` — either because the city is not in `simulatedResponses` or because decoding failed — the loop prints a plain "not found" message instead of ever touching a field on a value that might not exist.
var running = true
while running { print("""
===== WEATHER LOOKUP TOOL ===== Enter a city (Mumbai, Delhi, Bengaluru) or "exit" to quit: """) print("> ", terminator: "") guard let input = readLine(), !input.isEmpty else { print("Please enter a city name.") continue }
if input.lowercased() == "exit" { print("Goodbye!") running = false continue }
if let weather = fetchWeather(for: input) { print(formatWeather(weather)) // Reuses the closure from Step 4 to render the result } else { print("No weather data found for \(input).") }}Complete Code
Here is the full program assembled in the correct order, ready to save as `main.swift` and run with `swift main.swift`, or paste into a Swift Playground.
import Foundation
struct WeatherResponse: Codable { let city: String let temperature: Double let condition: String let feelsLike: Double? let humidity: Int? let alerts: [String]?}
let simulatedResponses: [String: String] = [ "mumbai": """ {"city": "Mumbai", "temperature": 31.5, "condition": "Humid", "feelsLike": 36.0, "humidity": 78, "alerts": ["Heavy rain expected"]} """, "delhi": """ {"city": "Delhi", "temperature": 24.0, "condition": "Clear", "humidity": 40} """, "bengaluru": """ {"city": "Bengaluru", "temperature": 22.5, "condition": "Cloudy", "feelsLike": 22.0} """]
func fetchWeather(for city: String) -> WeatherResponse? { guard let json = simulatedResponses[city.lowercased()] else { return nil } guard let data = json.data(using: .utf8) else { return nil } do { let decoder = JSONDecoder() return try decoder.decode(WeatherResponse.self, from: data) } catch { print("Failed to parse weather data for \(city): \(error)") return nil }}
let formatWeather: (WeatherResponse) -> String = { weather in var lines: [String] = [] lines.append("Weather for \(weather.city): \(weather.condition), \(weather.temperature)°C")
let feelsLikeText = weather.feelsLike.map { "\($0)°C" } ?? "not reported" lines.append("Feels like: \(feelsLikeText)")
let humidityText = weather.humidity.map { "\($0)%" } ?? "not reported" lines.append("Humidity: \(humidityText)")
if let alerts = weather.alerts, !alerts.isEmpty { lines.append("Alerts: \(alerts.joined(separator: ", "))") } else { lines.append("Alerts: none") }
return lines.joined(separator: "\n")}
var running = true
while running { print("""
===== WEATHER LOOKUP TOOL ===== Enter a city (Mumbai, Delhi, Bengaluru) or "exit" to quit: """) print("> ", terminator: "") guard let input = readLine(), !input.isEmpty else { print("Please enter a city name.") continue }
if input.lowercased() == "exit" { print("Goodbye!") running = false continue }
if let weather = fetchWeather(for: input) { print(formatWeather(weather)) } else { print("No weather data found for \(input).") }}Sample Run
Click Run to see what this code prints.
Extend This Project
- Swap the hardcoded dictionary for a real `URLSession.shared.dataTask` call against a live weather API.
- Add a `forecast: [DayForecast]?` array to `WeatherResponse` and render a multi-day outlook.
- Cache decoded `WeatherResponse` values in a `[String: WeatherResponse]` dictionary keyed by city to avoid re-decoding repeated lookups.
- Add a second closure that converts temperature to Fahrenheit, and let the user choose a unit at startup.
- Write unit tests with `XCTest` that feed known-good and deliberately malformed JSON into `fetchWeather` to confirm both paths behave correctly.
Summary
You built a weather lookup tool where `Codable` did the tedious work of turning JSON text into a typed Swift struct, and every field that could genuinely be missing was modeled as an Optional instead of assumed to always be present. The `do`/`catch` around `JSONDecoder().decode(...)` distinguished a real parsing failure from a simple "unknown city," and the closure-based formatter unwrapped every optional field with `map` and `??` instead of a single force-unwrap. That combination — `Codable` for parsing, Optionals for what might not exist, and closures for reusable formatting logic — is exactly how production Swift code handles data coming from outside the program, whether that is a real network response or, as here, a simulated one.