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: Codablestruct matching a realistic simulated JSON payload. - Optional fields (
feelsLike,humidity,alerts) that decode tonilwhen the JSON omits them. - A dictionary of simulated raw JSON strings standing in for a real weather API.
- A
fetchWeather(for:)function usingJSONDecoder().decode(...)inside ado/catch. - A closure-based
formatWeathervalue that unwraps every optional withmapand??before display. - A
readLine()-driven lookup loop tying it all together.
Prerequisites
- Structs — stored properties and
Codableconformance basics. - Optionals —
Optional<T>fields,if let,mapon an Optional, and nil-coalescing with??. - Closures — declaring a closure with an explicit type and calling it like a function.
- Error handling —
do/catchand thetrykeyword 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.dataTaskcall against a live weather API. - Add a
forecast: [DayForecast]?array toWeatherResponseand render a multi-day outlook. - Cache decoded
WeatherResponsevalues 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
XCTestthat feed known-good and deliberately malformed JSON intofetchWeatherto 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.