← All lessonsLesson 33 of 57 · Part 10: Data, persistence, and networking
Lesson 33

Trips that are still there tomorrow

Codable and JSON

You will buildA store that saves the trips as JSON in a file and reads them back at launch — proven by stopping the app and running it again — and a row of ideas in the planner, read from a JSON feed in the style of a web API, after walking into Codable's best-known trap and out again.

TimeAbout 70 minutes

WhyPart 10 is data: getting it in and out of the app. Nearly all of it travels as JSON, and in Swift JSON goes through Codable — two protocols, Encodable and Decodable, that the compiler can implement for you. Trips has carried a "a real save comes in Part 10" comment since lesson 6; in this lesson it comes. The second half is reading JSON you did not write, where the names and shapes are someone else's — and where the errors, read properly, say exactly what is wrong.

Step 1

Open your project

  1. Open your Trips project as lesson 32 left it: double-click Trips.xcodeproj in Finder, or use File ▸ Open… (⌘O) in Xcode.

  2. If you did not finish lesson 32, or would rather start from a known state, download Trips-lesson-32.zip instead. It is the project exactly as it stood at the end of lesson 32. Unzip it and open Trips.xcodeproj.

  3. Run it (⌘R) to check that it builds. You should see where lesson 32 ended:

    Trips, as it stood at the end of lesson 32 — screenshot
    Trips, as it stood at the end of lesson 32Screenshot · iPhone 17, iOS 27.0
Step 2

Save the trips

Since lesson 32, Trip is Codable: the compiler wrote the code that turns a trip into JSON and back, property by property. Apple's page for Codable: "A type that can convert itself into and out of an external representation." An array of trips is Codable too, because its elements are.

Saving is three lines: a JSONEncoder turns trips into Data, and the data is written to a file in the app's own documents folder — URL.documentsDirectory, which no other app can see. .atomic writes to a temporary file first and swaps it in, so a crash halfway never leaves half a file. Reading is the same backwards, in the store's init: if there is a file and it decodes, those are the trips; otherwise, the samples. Two choices about the JSON: dates are written as ISO 8601 text rather than the default — a number of seconds since 2001 — and the output is indented and sorted, to be read by people. The decoder has to agree with the encoder about dates. For now save() also prints what it wrote. In TripStore.swift:

In TripStore.swift, replace the TripStore class with this
/// The trips, for the whole app. `@MainActor`: the views read it, and the views/// are drawn on the main thread.@MainActor@Observablefinal class TripStore {    var trips = Trip.samples    var trips: [Trip]    /// Where the trips are kept between launches: a file in the app's own documents folder.    static let file = URL.documentsDirectory.appending(path: "trips.json")    /// The trips saved last time — or, when there are none yet, the samples.    init() {        do {            let data = try Data(contentsOf: Self.file)            let decoder = JSONDecoder()            decoder.dateDecodingStrategy = .iso8601            trips = try decoder.decode([Trip].self, from: data)        } catch {            trips = Trip.samples        }    }    /// Adds the trip a draft describes, with an id no other trip has.    func add(_ draft: TripDraft) {        let id = (trips.map(\.id).max() ?? 0) + 1        trips.append(draft.trip(id: id))    }    /// Removes these trips, whatever order they are in.    func delete(_ doomed: [Trip]) {        let ids = Set(doomed.map(\.id))        trips.removeAll { ids.contains($0.id) }    }    /// Makes a trip a favorite, or not.    func toggleFavorite(_ trip: Trip) {        if let index = trips.firstIndex(where: { $0.id == trip.id }) {            trips[index].isFavorite.toggle()        }    }    /// Replaces the trip with the same id.    func update(_ trip: Trip) {        if let index = trips.firstIndex(where: { $0.id == trip.id }) {            trips[index] = trip        }    }    /// Moves these trips in front of another one — or, with no other one, to the end.    func move(_ ids: [Trip.ID], before target: Trip.ID?) {        let moving = trips.filter { ids.contains($0.id) }        trips.removeAll { ids.contains($0.id) }        let index = target.flatMap { id in trips.firstIndex { $0.id == id } } ?? trips.endIndex        trips.insert(contentsOf: moving, at: index)    }    /// Makes these trips favorites.    func makeFavorites(_ dropped: [Trip]) {        for trip in dropped {            if let index = trips.firstIndex(where: { $0.id == trip.id }) {                trips[index].isFavorite = true            }        }    }    /// Writes the trips to the file as JSON.    func save() {        print("Saving \(trips.count) trips")       // a real save comes in Part 10        do {            let encoder = JSONEncoder()            encoder.dateEncodingStrategy = .iso8601            encoder.outputFormatting = [.prettyPrinted, .sortedKeys]            let data = try encoder.encode(trips)            try data.write(to: Self.file, options: .atomic)            print(String(decoding: data, as: UTF8.self))        } catch {            print("Could not save the trips:", error)        }    }}
/// The trips, for the whole app. `@MainActor`: the views read it, and the views
/// are drawn on the main thread.
@MainActor
@Observable
final class TripStore {
    var trips: [Trip]

    /// Where the trips are kept between launches: a file in the app's own documents folder.
    static let file = URL.documentsDirectory.appending(path: "trips.json")

    /// The trips saved last time — or, when there are none yet, the samples.
    init() {
        do {
            let data = try Data(contentsOf: Self.file)
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .iso8601
            trips = try decoder.decode([Trip].self, from: data)
        } catch {
            trips = Trip.samples
        }
    }

    /// Adds the trip a draft describes, with an id no other trip has.
    func add(_ draft: TripDraft) {
        let id = (trips.map(\.id).max() ?? 0) + 1
        trips.append(draft.trip(id: id))
    }

    /// Removes these trips, whatever order they are in.
    func delete(_ doomed: [Trip]) {
        let ids = Set(doomed.map(\.id))
        trips.removeAll { ids.contains($0.id) }
    }

    /// Makes a trip a favorite, or not.
    func toggleFavorite(_ trip: Trip) {
        if let index = trips.firstIndex(where: { $0.id == trip.id }) {
            trips[index].isFavorite.toggle()
        }
    }

    /// Replaces the trip with the same id.
    func update(_ trip: Trip) {
        if let index = trips.firstIndex(where: { $0.id == trip.id }) {
            trips[index] = trip
        }
    }

    /// Moves these trips in front of another one — or, with no other one, to the end.
    func move(_ ids: [Trip.ID], before target: Trip.ID?) {
        let moving = trips.filter { ids.contains($0.id) }
        trips.removeAll { ids.contains($0.id) }
        let index = target.flatMap { id in trips.firstIndex { $0.id == id } } ?? trips.endIndex
        trips.insert(contentsOf: moving, at: index)
    }

    /// Makes these trips favorites.
    func makeFavorites(_ dropped: [Trip]) {
        for trip in dropped {
            if let index = trips.firstIndex(where: { $0.id == trip.id }) {
                trips[index].isFavorite = true
            }
        }
    }

    /// Writes the trips to the file as JSON.
    func save() {
        do {
            let encoder = JSONEncoder()
            encoder.dateEncodingStrategy = .iso8601
            encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
            let data = try encoder.encode(trips)
            try data.write(to: Self.file, options: .atomic)
            print(String(decoding: data, as: UTF8.self))
        } catch {
            print("Could not save the trips:", error)
        }
    }
}

Run the app. The app saves as soon as it starts — the store's trips are watched (lesson 22) — and the console shows the file:

Console
[
  {
    "city" : "Seattle",
    "country" : "United States",
    "id" : 1,
    "imageName" : "seattle",
    "isFavorite" : true,
    "nights" : 5,
    "notes" : "**Pike Place** early, before the crowds.",
    "price" : 1249.5,
    "start" : "2027-06-12T04:00:00Z"
  },
  {
    "city" : "Vancouver",
    "country" : "Canada",
    "id" : 2,
    "imageName" : "vancouver",
    "isFavorite" : false,
    "nights" : 1,
    "notes" : "One night only: *Stanley Park* at sunset.",
    "price" : 320,
    "start" : "2027-06-17T04:00:00Z"
  },
  {
    "city" : "San Pedro de Atacama",
    "country" : "Chile",
    "id" : 3,
    "imageName" : "atacama",
    "isFavorite" : true,
    "nights" : 12,
    "notes" : "Stars. Bring a **warm** coat.",
    "price" : 4180,
    "start" : "2027-11-02T04:00:00Z"
  }
]

Every stored property is there, under its own name: the Decimal price as a number, the Date as text, the Markdown in the notes as written. Nothing had to be spelled out, because each property's type is itself Codable.

Step 3

Still there after a restart

A file is only worth writing if it is read again. Replace the print of the whole file with a short line, in TripStore.swift:

In TripStore.swift, replace the TripStore class with this
/// The trips, for the whole app. `@MainActor`: the views read it, and the views/// are drawn on the main thread.@MainActor@Observablefinal class TripStore {    var trips: [Trip]    /// Where the trips are kept between launches: a file in the app's own documents folder.    static let file = URL.documentsDirectory.appending(path: "trips.json")    /// The trips saved last time — or, when there are none yet, the samples.    init() {        do {            let data = try Data(contentsOf: Self.file)            let decoder = JSONDecoder()            decoder.dateDecodingStrategy = .iso8601            trips = try decoder.decode([Trip].self, from: data)        } catch {            trips = Trip.samples        }    }    /// Adds the trip a draft describes, with an id no other trip has.    func add(_ draft: TripDraft) {        let id = (trips.map(\.id).max() ?? 0) + 1        trips.append(draft.trip(id: id))    }    /// Removes these trips, whatever order they are in.    func delete(_ doomed: [Trip]) {        let ids = Set(doomed.map(\.id))        trips.removeAll { ids.contains($0.id) }    }    /// Makes a trip a favorite, or not.    func toggleFavorite(_ trip: Trip) {        if let index = trips.firstIndex(where: { $0.id == trip.id }) {            trips[index].isFavorite.toggle()        }    }    /// Replaces the trip with the same id.    func update(_ trip: Trip) {        if let index = trips.firstIndex(where: { $0.id == trip.id }) {            trips[index] = trip        }    }    /// Moves these trips in front of another one — or, with no other one, to the end.    func move(_ ids: [Trip.ID], before target: Trip.ID?) {        let moving = trips.filter { ids.contains($0.id) }        trips.removeAll { ids.contains($0.id) }        let index = target.flatMap { id in trips.firstIndex { $0.id == id } } ?? trips.endIndex        trips.insert(contentsOf: moving, at: index)    }    /// Makes these trips favorites.    func makeFavorites(_ dropped: [Trip]) {        for trip in dropped {            if let index = trips.firstIndex(where: { $0.id == trip.id }) {                trips[index].isFavorite = true            }        }    }    /// Writes the trips to the file as JSON.    func save() {        do {            let encoder = JSONEncoder()            encoder.dateEncodingStrategy = .iso8601            encoder.outputFormatting = [.prettyPrinted, .sortedKeys]            let data = try encoder.encode(trips)            try data.write(to: Self.file, options: .atomic)            print(String(decoding: data, as: UTF8.self))            print("Saved \(trips.count) trips: \(data.count) bytes")        } catch {            print("Could not save the trips:", error)        }    }}
/// The trips, for the whole app. `@MainActor`: the views read it, and the views
/// are drawn on the main thread.
@MainActor
@Observable
final class TripStore {
    var trips: [Trip]

    /// Where the trips are kept between launches: a file in the app's own documents folder.
    static let file = URL.documentsDirectory.appending(path: "trips.json")

    /// The trips saved last time — or, when there are none yet, the samples.
    init() {
        do {
            let data = try Data(contentsOf: Self.file)
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .iso8601
            trips = try decoder.decode([Trip].self, from: data)
        } catch {
            trips = Trip.samples
        }
    }

    /// Adds the trip a draft describes, with an id no other trip has.
    func add(_ draft: TripDraft) {
        let id = (trips.map(\.id).max() ?? 0) + 1
        trips.append(draft.trip(id: id))
    }

    /// Removes these trips, whatever order they are in.
    func delete(_ doomed: [Trip]) {
        let ids = Set(doomed.map(\.id))
        trips.removeAll { ids.contains($0.id) }
    }

    /// Makes a trip a favorite, or not.
    func toggleFavorite(_ trip: Trip) {
        if let index = trips.firstIndex(where: { $0.id == trip.id }) {
            trips[index].isFavorite.toggle()
        }
    }

    /// Replaces the trip with the same id.
    func update(_ trip: Trip) {
        if let index = trips.firstIndex(where: { $0.id == trip.id }) {
            trips[index] = trip
        }
    }

    /// Moves these trips in front of another one — or, with no other one, to the end.
    func move(_ ids: [Trip.ID], before target: Trip.ID?) {
        let moving = trips.filter { ids.contains($0.id) }
        trips.removeAll { ids.contains($0.id) }
        let index = target.flatMap { id in trips.firstIndex { $0.id == id } } ?? trips.endIndex
        trips.insert(contentsOf: moving, at: index)
    }

    /// Makes these trips favorites.
    func makeFavorites(_ dropped: [Trip]) {
        for trip in dropped {
            if let index = trips.firstIndex(where: { $0.id == trip.id }) {
                trips[index].isFavorite = true
            }
        }
    }

    /// Writes the trips to the file as JSON.
    func save() {
        do {
            let encoder = JSONEncoder()
            encoder.dateEncodingStrategy = .iso8601
            encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
            let data = try encoder.encode(trips)
            try data.write(to: Self.file, options: .atomic)
            print("Saved \(trips.count) trips: \(data.count) bytes")
        } catch {
            print("Could not save the trips:", error)
        }
    }
}

Run the app and make Vancouver a favorite with its star. Then stop the app in Xcode (Stop, ⌘.) and run it again (⌘R), tap the Favorites chip, and scroll down to the cards:

After the restart, with the Favorites chip on: Vancouver is there, its star filled — screenshot
After the restart, with the Favorites chip on: Vancouver is there, its star filledScreenshot · iPhone 17, iOS 27.0

Vancouver is still among the favorites after the app was stopped and started again: the store read it back from the file. Stopping gives an app no warning at all, and it lost nothing, because the star's change was saved the moment it happened. The console shows the saves before the restart — one at launch, one for the star, a byte shorter than the first because false became true:

Console
Saved 3 trips: 798 bytes
Saved 3 trips: 797 bytes
Step 4

Ideas from a feed

JSON you write yourself is easy: your names, your shapes. JSON from a web service uses its own. Here is a small feed of trip ideas written the way most web APIs write it, with names in snake_case — suggested_nights, image_url — and an Idea struct with Swift's camelCase names. A decoder can translate between the two: .convertFromSnakeCase, which Apple describes as "A key decoding strategy that converts snake-case keys to camel-case keys." For now the feed is a string in the app; in the next lesson, data like it arrives over the network. Make a new Swift File named Ideas, and replace everything in it:

New fileIdeas.swift — the whole file
import Foundation

/// Ideas for trips, written the way a web service writes JSON: names in snake_case.
let ideasJSON = """
[
    {"city": "Bergen", "country": "Norway", "suggested_nights": 4, "image_url": "https://example.com/bergen.jpg"},
    {"city": "Kyoto", "country": "Japan", "suggested_nights": 7, "image_url": "https://example.com/kyoto.jpg"}
]
"""

/// One idea from the feed.
struct Idea: Decodable, Identifiable {
    let city: String
    let country: String
    let suggestedNights: Int
    let imageURL: URL

    var id: String {
        city
    }

    /// Reads the feed: suggested_nights becomes suggestedNights.
    static func load() throws -> [Idea] {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return try decoder.decode([Idea].self, from: Data(ideasJSON.utf8))
    }
}

A row of buttons shows the ideas — or, if the feed cannot be read, what the decoder said. Result(catching:) runs a throwing call and keeps either the value or the error. Make a new SwiftUI View named IdeasRow, and replace everything in it:

New fileIdeasRow.swift — the whole file
import SwiftUI

/// The feed's ideas as buttons — or, if the feed cannot be read, why not.
struct IdeasRow: View {
    let choose: (Idea) -> Void

    var body: some View {
        switch Result(catching: { try Idea.load() }) {
        case .success(let ideas):
            HStack {
                Text("Ideas")
                    .foregroundStyle(.secondary)
                ForEach(ideas) { idea in
                    Button(idea.city) { choose(idea) }
                        .buttonStyle(.bordered)
                }
            }
        case .failure(let error):
            Text("The ideas could not be read: \(String(describing: error))")
                .font(.caption)
                .foregroundStyle(.red)
                .onAppear { print(error) }
        }
    }
}

And the planner shows the row when it is open; choosing an idea fills in the draft. In TripPlanner.swift:

In TripPlanner.swift, replace the TripPlanner struct with this
struct TripPlanner: View {    @Environment(TripStore.self) private var store    @State private var isOpen = false    @State private var draft = TripDraft()    @Environment(\.horizontalSizeClass) private var sizeClass    var body: some View {        VStack(spacing: 12) {            VStack(alignment: .leading, spacing: 12) {                Button(isOpen ? "Close the planner" : "Plan a trip", systemImage: isOpen ? "xmark" : "plus") {                    isOpen.toggle()                }                .buttonStyle(.bordered)                if isOpen {                    IdeasRow { idea in                        draft.city = idea.city                        draft.country = idea.country                        draft.nights = idea.suggestedNights                    }                    let fields = sizeClass == .regular ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout(alignment: .leading))                    fields {                        TextField("City", text: $draft.city)                        TextField("Country", text: $draft.country)                    }                    Stepper(value: $draft.nights, in: 1...60) {                        Text("^[\(draft.nights) night](inflect: true)")                    }                    Stepper(value: $draft.price, in: 0...20_000, step: 250) {                        Text(draft.price, format: .currency(code: "USD"))                    }                    HStack {                        PicturePicker(selection: $draft.imageName)                        Spacer()                        FavoriteButton(isFavorite: $draft.isFavorite)                    }                    Button("Add to my trips") {                        store.add(draft)                        draft = TripDraft()                        isOpen = false                    }                    .buttonStyle(.borderedProminent)                    .disabled(draft.city.isEmpty)                }            }            .textFieldStyle(.roundedBorder)            .frame(maxWidth: .infinity, alignment: .leading)            .padding(.horizontal)            if isOpen {                TripCard(trip: Binding(                    get: { draft.trip(id: 0) },                    set: { draft.isFavorite = $0.isFavorite }                ))            }        }    }}
struct TripPlanner: View {
    @Environment(TripStore.self) private var store
    @State private var isOpen = false
    @State private var draft = TripDraft()
    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        VStack(spacing: 12) {
            VStack(alignment: .leading, spacing: 12) {
                Button(isOpen ? "Close the planner" : "Plan a trip", systemImage: isOpen ? "xmark" : "plus") {
                    isOpen.toggle()
                }
                .buttonStyle(.bordered)
                if isOpen {
                    IdeasRow { idea in
                        draft.city = idea.city
                        draft.country = idea.country
                        draft.nights = idea.suggestedNights
                    }
                    let fields = sizeClass == .regular ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout(alignment: .leading))
                    fields {
                        TextField("City", text: $draft.city)
                        TextField("Country", text: $draft.country)
                    }
                    Stepper(value: $draft.nights, in: 1...60) {
                        Text("^[\(draft.nights) night](inflect: true)")
                    }
                    Stepper(value: $draft.price, in: 0...20_000, step: 250) {
                        Text(draft.price, format: .currency(code: "USD"))
                    }
                    HStack {
                        PicturePicker(selection: $draft.imageName)
                        Spacer()
                        FavoriteButton(isFavorite: $draft.isFavorite)
                    }
                    Button("Add to my trips") {
                        store.add(draft)
                        draft = TripDraft()
                        isOpen = false
                    }
                    .buttonStyle(.borderedProminent)
                    .disabled(draft.city.isEmpty)
                }
            }
            .textFieldStyle(.roundedBorder)
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding(.horizontal)
            if isOpen {
                TripCard(trip: Binding(
                    get: { draft.trip(id: 0) },
                    set: { draft.isFavorite = $0.isFavorite }
                ))
            }
        }
    }
}

Run the app and tap Plan a trip:

The planner, and the decoder's complaint — screenshot
The planner, and the decoder's complaintScreenshot · iPhone 17, iOS 27.0

No ideas. The console has the whole error:

Console
Saved 3 trips: 798 bytes
DecodingError.keyNotFound: Key 'imageURL' not found in keyed decoding container. Path: [0]. Debug description: No value associated with key CodingKeys(stringValue: "imageURL", intValue: nil) ("imageURL").

Read it, and it says what is wrong: no key named imageURL. .convertFromSnakeCase turned image_url into imageUrl — its first step is to "Capitalize each word that follows an underscore", and that is all it does to a word — and imageUrl is not imageURL. suggested_nights became suggestedNights without trouble; any name with an acronym in it is the trap. Apple's page says so itself: the strategy "can’t infer capitalization for acronyms or initialisms such as WYSIWYG or URI."

Step 5

Keys of your own

A CodingKeys enum tells Codable which name in the JSON goes with each property. With the snake-case strategy on, the names it compares are the converted ones, so imageURL maps to "imageUrl". Every property needs a case, even the ones whose names already match. In Ideas.swift:

In Ideas.swift, replace the Idea struct and its feed with this
import Foundation/// Ideas for trips, written the way a web service writes JSON: names in snake_case.let ideasJSON = """[    {"city": "Bergen", "country": "Norway", "suggested_nights": 4, "image_url": "https://example.com/bergen.jpg"},    {"city": "Kyoto", "country": "Japan", "suggested_nights": 7, "image_url": "https://example.com/kyoto.jpg"}]"""/// One idea from the feed.struct Idea: Decodable, Identifiable {    let city: String    let country: String    let suggestedNights: Int    let imageURL: URL    var id: String {        city    }    /// The names the feed uses, once .convertFromSnakeCase has turned image_url into imageUrl.    enum CodingKeys: String, CodingKey {        case city, country, suggestedNights        case imageURL = "imageUrl"    }    /// Reads the feed: suggested_nights becomes suggestedNights.    static func load() throws -> [Idea] {        let decoder = JSONDecoder()        decoder.keyDecodingStrategy = .convertFromSnakeCase        return try decoder.decode([Idea].self, from: Data(ideasJSON.utf8))    }}
import Foundation

/// Ideas for trips, written the way a web service writes JSON: names in snake_case.
let ideasJSON = """
[
    {"city": "Bergen", "country": "Norway", "suggested_nights": 4, "image_url": "https://example.com/bergen.jpg"},
    {"city": "Kyoto", "country": "Japan", "suggested_nights": 7, "image_url": "https://example.com/kyoto.jpg"}
]
"""

/// One idea from the feed.
struct Idea: Decodable, Identifiable {
    let city: String
    let country: String
    let suggestedNights: Int
    let imageURL: URL

    var id: String {
        city
    }

    /// The names the feed uses, once .convertFromSnakeCase has turned image_url into imageUrl.
    enum CodingKeys: String, CodingKey {
        case city, country, suggestedNights
        case imageURL = "imageUrl"
    }

    /// Reads the feed: suggested_nights becomes suggestedNights.
    static func load() throws -> [Idea] {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return try decoder.decode([Idea].self, from: Data(ideasJSON.utf8))
    }
}

Run the app, open the planner, and tap Bergen:

The ideas, as buttons; Bergen chosen — screenshot
The ideas, as buttons; Bergen chosenScreenshot · iPhone 17, iOS 27.0

Bergen and Kyoto, read from the feed; a tap on Bergen filled in its city, its country and its four nights.

What you built

Next, loading data that takes time to arrive: async/await, a loading state, an error with a Retry button, and pull-to-refresh.

The finished project

Stuck, or want to compare with your own? This is the project as it stands at the end of this lesson, built and run before it was put here.

Flash cards

15 cards for this lesson, each labelled with its topic. 9 ask how you would write something; the other 6 are trick questions, written to catch a misunderstanding. Answer in your head first, then tap the card.

Syntax · EncodingTurn a Codable value into JSON, and print it.
let data = try JSONEncoder().encode(Trip(city: "Seattle", nights: 5))
print(String(decoding: data, as: UTF8.self))

JSONEncoder().encode(_:) gives Data; String(decoding:as:) makes it readable. It printed {"city":"Seattle","nights":5} — but the keys come in no fixed order, and other runs of the same program put them the other way round. Sort them when the order matters (the next card).

Syntax · DecodingTurn JSON into a value of your own type.
let trip = try JSONDecoder().decode(Trip.self, from: Data(#"{"city": "Seattle", "nights": 5}"#.utf8))
print(trip.city, trip.nights)

JSONDecoder().decode(Trip.self, from: data) — the type to make, and the data. It throws if the JSON does not fit. It printed Seattle 5.

Syntax · EncodingMake the JSON readable: indented, with its keys in order.
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
print(String(decoding: try encoder.encode(Trip(city: "Seattle", nights: 5)), as: UTF8.self))

outputFormatting = [.prettyPrinted, .sortedKeys]: one key to a line, indented, in alphabetical order — the first line after the brace was "city" : "Seattle",. Sorted keys also make the output the same every time, which helps in tests.

Syntax · DatesWrite dates into JSON as ISO 8601 text — and read them back.
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(Stamp(start: Date(timeIntervalSince1970: 0)))
print(String(decoding: data, as: UTF8.self))
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
print(try decoder.decode(Stamp.self, from: data).start == Date(timeIntervalSince1970: 0))

dateEncodingStrategy = .iso8601 on the encoder and dateDecodingStrategy = .iso8601 on the decoder — the two must agree. It wrote {"start":"1970-01-01T00:00:00Z"}, and read it back as the same date: true.

Syntax · KeysRead JSON whose keys are snake_case into camelCase properties.
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let idea = try decoder.decode(Idea.self, from: Data(#"{"city": "Bergen", "suggested_nights": 4}"#.utf8))
print(idea.suggestedNights)

keyDecodingStrategy = .convertFromSnakeCase. It printed 4. Watch names with acronyms: image_url becomes imageUrl, never imageURL.

Syntax · KeysGive a property a different name from the key in the JSON.
import Foundation

struct Idea: Decodable {
    let city: String
    let imageURL: URL

    enum CodingKeys: String, CodingKey {
        case city
        case imageURL = "image_url"
    }
}

A CodingKeys enum, String-backed: each case is a property, and its raw value the key. Every property needs a case. It printed https://example.com/bergen.jpg.

Syntax · DecodingAllow a key to be missing from the JSON.
import Foundation

struct Trip: Decodable {
    let city: String
    let notes: String?
}

Make the property optional: a missing key — or null — decodes as nil. It printed nil.

Syntax · DecodingRead a value from inside a nested JSON object into a flat property.
import Foundation

struct Flight: Decodable {
    let number: String
    let departs: String

    enum CodingKeys: String, CodingKey {
        case number, schedule
    }
    enum ScheduleKeys: String, CodingKey {
        case departs
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        number = try container.decode(String.self, forKey: .number)
        let schedule = try container.nestedContainer(keyedBy: ScheduleKeys.self, forKey: .schedule)
        departs = try schedule.decode(String.self, forKey: .departs)
    }
}

Write init(from:) yourself, and reach in with nestedContainer(keyedBy:forKey:). It printed AS 101 08:40.

Syntax · ErrorsFind out which key was missing when decoding fails.
do {
    _ = try JSONDecoder().decode(Trip.self, from: Data(#"{"city": "Seattle"}"#.utf8))
} catch DecodingError.keyNotFound(let key, let context) {
    print("missing:", key.stringValue, "at:", context.codingPath.map(\.stringValue))
} catch {
    print(error)
}

Catch DecodingError.keyNotFound(let key, let context): the key, and the path to where it should have been. It printed missing: nights at: [].

Trick question · DatesA Date is encoded with a plain JSONEncoder(). What appears in the JSON?

A number — and not the Unix one: {"start":-978307200}. The default counts seconds from 1 January 2001, Apple's reference date, so the start of 1970 is negative. Servers usually mean seconds since 1970: say so with .secondsSince1970, or use .iso8601.

Trick question · KeysWith .convertFromSnakeCase, does the key image_url fill a property named imageURL?

No: "DecodingError.keyNotFound: Key 'imageURL' not found in keyed decoding container. Debug description: No value associated with key CodingKeys(stringValue: "imageURL", intValue: nil) ("imageURL")." The strategy makes imageUrl. Map it with CodingKeys: case imageURL = "imageUrl".

Trick question · DecodingAn array of three places is decoded; the second lacks a required key. How many places come back?

None — the whole decode throws: "DecodingError.keyNotFound: Key 'nights' not found in keyed decoding container. Path: [1]. Debug description: No value associated with key CodingKeys(stringValue: "nights", intValue: nil) ("nights")." The path, [1], is the second element. One bad element sinks the array unless you decode the elements one by one.

Trick question · DecodingThe JSON has a key that the struct does not have. Does decoding fail?

No — the extra key is ignored, and gone if you encode the value again: {"name":"Seattle"}. A round trip through your struct drops what it does not know.

Trick question · DecodingA property is String?, and the JSON has "notes": 5. Does it decode as nil?

No, it throws: "DecodingError.typeMismatch: Expected value of type String. Path: notes. Debug description: Expected to decode String but found number instead." An optional forgives a missing key or null — not a value of the wrong type.

Trick question · Encodingstruct Trip: Codable { let id = UUID(); let city: String }. Is id read from the JSON?

No — and the compiler says so: "immutable property will not be decoded because it is declared with an initial value which cannot be overwritten" A let with a value can never be set again, so decoding skips it: this trip got a new id, and the comparison printed `. (An edge case in this lesson tries var`.)

Edge cases and tricky questions

10 questions of the kind an interviewer uses to find out whether you really know this — the corners, the traps, and the things that changed recently. Each has its source. Wherever the answer is what some code does, that code was compiled and run, and what you see under “Show the answer” is what it really did. Say your answer aloud before you open it.

Lesson 33 · question 1 of 10 · a key with an acronym in it

The JSON says "image_url". The struct says let imageURL: URL. The decoder uses .convertFromSnakeCase. What happens — and what are the two fixes?

import Foundation

struct Idea: Decodable {
    let city: String
    let imageURL: URL
}

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
do {
    _ = try decoder.decode(Idea.self, from: Data(#"{"city": "Bergen", "image_url": "https://example.com/bergen.jpg"}"#.utf8))
} catch {
    print(error)
}
Show the answer

It throws — no key imageURL: "DecodingError.keyNotFound: Key 'imageURL' not found in keyed decoding container. Debug description: No value associated with key CodingKeys(stringValue: "imageURL", intValue: nil) ("imageURL")."

The strategy capitalizes the word after each underscore and nothing else, so image_url becomes imageUrl. Apple's page for it: it "can’t infer capitalization for acronyms or initialisms such as WYSIWYG or URI", and it points to "a custom CodingKeys enumeration".

Fix one: keep the strategy, and give the converted name — "imageUrl" — in CodingKeys:

import Foundation

struct Idea: Decodable {
    let city: String
    let imageURL: URL

    enum CodingKeys: String, CodingKey {
        case city
        case imageURL = "imageUrl"
    }
}

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
print(try decoder.decode(Idea.self, from: Data(#"{"city": "Bergen", "image_url": "https://example.com/bergen.jpg"}"#.utf8)).imageURL)

Fix two: drop the strategy, and spell out the JSON's own names:

import Foundation

struct Idea: Decodable {
    let city: String
    let imageURL: URL

    enum CodingKeys: String, CodingKey {
        case city
        case imageURL = "image_url"
    }
}

print(try JSONDecoder().decode(Idea.self, from: Data(#"{"city": "Bergen", "image_url": "https://example.com/bergen.jpg"}"#.utf8)).imageURL)

Both printed the URL. The second has one rule fewer to remember: what is in CodingKeys is exactly what is in the JSON.

Where this comes from: JSONDecoder.KeyDecodingStrategy.convertFromSnakeCase · CodingKey — Measured with Xcode 27.

Lesson 33 · question 2 of 10 · a timestamp from a server

A server sends "at": 1781222400 — seconds since 1970, as servers do. The Date is decoded with a plain JSONDecoder(). What date comes out?

import Foundation

struct Departure: Decodable {
    let at: Date
}

let json = Data(#"{"at": 1781222400}"#.utf8)
let plain = try JSONDecoder().decode(Departure.self, from: json)
print("plain:", plain.at.formatted(.iso8601))
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
print("since 1970:", try decoder.decode(Departure.self, from: json).at.formatted(.iso8601))
Show the answer

One 31 years late:

It printed

Console
plain: 2057-06-12T00:00:00Z
since 1970: 2026-06-12T00:00:00Z

No error — just a wrong date. The default strategy, .deferredToDate, leaves the work to Date itself, which reads a number as seconds since 1 January 2001, Apple's reference date; a server almost always means 1 January 1970. Apple's page for the date strategies lists .secondsSince1970, .millisecondsSince1970, .iso8601 and others: choose the one the server uses, and test it with a known date.

Where this comes from: JSONDecoder.DateDecodingStrategy · JSONDecoder — Measured with Xcode 27.

Lesson 33 · question 3 of 10 · an encoder and a decoder that disagree

A file was written with dateEncodingStrategy = .iso8601. It is read back with a plain JSONDecoder(). What happens?

import Foundation

struct Stamp: Codable {
    let start: Date
}

let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(Stamp(start: Date(timeIntervalSince1970: 0)))
do {
    _ = try JSONDecoder().decode(Stamp.self, from: data)
} catch {
    print(error)
}
Show the answer

It throws:

It printed

Console
DecodingError.typeMismatch: Expected value of type Double. Path: start. Debug description: Expected to decode Double but found a string instead.

The plain decoder expects dates as numbers and found text. In an app that falls back to sample data when its file does not decode — as this lesson's Trips does — this is silent: every launch starts again from the samples. Keep the encoder's and decoder's settings side by side, as TripStore does, so that they cannot drift apart.

Where this comes from: JSONDecoder.DateDecodingStrategy · DecodingError — Measured with Xcode 27.

Lesson 33 · question 4 of 10 · one bad element

A feed of three places is decoded as [Place]; the second has no nights. How many places come back — and how would you keep the good ones?

import Foundation

struct Place: Decodable {
    let name: String
    let nights: Int
}

do {
    let places = try JSONDecoder().decode([Place].self, from: Data(#"[{"name": "Seattle", "nights": 5}, {"name": "Bergen"}, {"name": "Lima", "nights": 3}]"#.utf8))
    print(places.count)
} catch {
    print(error)
}
Show the answer

None — the whole decode throws:

It printed

Console
DecodingError.keyNotFound: Key 'nights' not found in keyed decoding container. Path: [1]. Debug description: No value associated with key CodingKeys(stringValue: "nights", intValue: nil) ("nights").

The path says where: [1], the second element. An array decodes all or nothing. To keep the good elements, decode them one at a time from an unkeyed container, and step past the ones that fail:

import Foundation

struct Place: Decodable {
    let name: String
    let nights: Int
}

/// Decodes what it can, and steps past the elements that do not fit.
struct Places: Decodable {
    let places: [Place]

    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        var good: [Place] = []
        while !container.isAtEnd {
            if let place = try? container.decode(Place.self) {
                good.append(place)
            } else {
                _ = try? container.decode(Skip.self)
            }
        }
        places = good
    }

    private struct Skip: Decodable {}
}

print(try JSONDecoder().decode(Places.self, from: Data(#"[{"name": "Seattle", "nights": 5}, {"name": "Bergen"}, {"name": "Lima", "nights": 3}]"#.utf8)).places.map(\.name))

It printed ["Seattle", "Lima"]. The Skip decode is what moves past a bad element: a decode that fails leaves the container where it was, so without it the loop would try the same element again and again.

Where this comes from: DecodingError · Encoding and Decoding Custom Types — Measured with Xcode 27.

Lesson 33 · question 5 of 10 · a key the struct does not know

The JSON has a key the struct has no property for. Does decoding fail? And what does encoding the value again write?

import Foundation

struct Place: Codable {
    let name: String
}

let place = try JSONDecoder().decode(Place.self, from: Data(#"{"name": "Seattle", "rating": 4.8}"#.utf8))
print(String(decoding: try JSONEncoder().encode(place), as: UTF8.self))
Show the answer

It decodes — and the rating is gone: {"name":"Seattle"}.

Unknown keys are ignored, which is what makes a decoder tolerant of a server that adds fields. The cost shows in a round trip: an app that decodes a record, changes one field and sends it all back has deleted every field it did not model. When that matters, model the fields, or send only what changed.

Where this comes from: JSONDecoder — Measured with Xcode 27.

Lesson 33 · question 6 of 10 · an optional of the wrong type

let notes: String?. Three JSON objects: no notes key, "notes": null, and "notes": 5. Which decode?

import Foundation

struct Trip: Decodable {
    let notes: String?
}

for json in [Data(#"{}"#.utf8), Data(#"{"notes": null}"#.utf8), Data(#"{"notes": 5}"#.utf8)] {
    do {
        print(try JSONDecoder().decode(Trip.self, from: json).notes as Any)
    } catch {
        print(error)
    }
}
Show the answer

The first two, as nil; the third throws:

It printed

Console
nil
nil
DecodingError.typeMismatch: Expected value of type String. Path: notes. Debug description: Expected to decode String but found number instead.

An optional means this may be absent: a missing key or null both give nil. A value of the wrong type is still a mismatch, and the whole decode fails. If a server sends numbers and strings in the same field, write init(from:) and try each type in turn.

Where this comes from: DecodingError — Measured with Xcode 27.

Lesson 33 · question 7 of 10 · an enum the server outgrew

enum Mode: String, Decodable { case flight, ferry }. The server starts sending "train". What happens — and how do you survive it?

import Foundation

enum Mode: String, Decodable {
    case flight, ferry
}

struct Leg: Decodable {
    let mode: Mode
}

do {
    _ = try JSONDecoder().decode(Leg.self, from: Data(#"{"mode": "train"}"#.utf8))
} catch {
    print(error)
}
Show the answer

The whole decode fails:

It printed

Console
DecodingError.dataCorrupted: Data was corrupted. Path: mode. Debug description: Cannot initialize Mode from invalid String value train

A new case on the server breaks every installed version of the app that reads it. Give the enum a case for what it does not know, and an init(from:) that falls back to it:

import Foundation

enum Mode: String, Decodable {
    case flight, ferry, other

    init(from decoder: Decoder) throws {
        let raw = try decoder.singleValueContainer().decode(String.self)
        self = Mode(rawValue: raw) ?? .other
    }
}

struct Leg: Decodable {
    let mode: Mode
}

print(try JSONDecoder().decode(Leg.self, from: Data(#"{"mode": "train"}"#.utf8)).mode)

It printed other. Then decide what the app does with .other — show it generically, or hide it — rather than refusing the whole response.

Where this comes from: DecodingError · Encoding and Decoding Custom Types — Measured with Xcode 27.

Lesson 33 · question 8 of 10 · money in JSON

A total is computed as 0.1 + 0.2 and encoded — once as a Double, once as a Decimal. What does each JSON say?

import Foundation

struct DoubleTotal: Codable {
    let total: Double
}
struct DecimalTotal: Codable {
    let total: Decimal
}

print(String(decoding: try JSONEncoder().encode(DoubleTotal(total: 0.1 + 0.2)), as: UTF8.self))
print(String(decoding: try JSONEncoder().encode(DecimalTotal(total: Decimal(string: "0.1")! + Decimal(string: "0.2")!)), as: UTF8.self))
Show the answer

The Double is off in the 17th digit; the Decimal is exact:

It printed

Console
{"total":0.30000000000000004}
{"total":0.3}

A Double is binary, and neither 0.1 nor 0.2 has an exact binary value; their sum shows the error, and the encoder writes it faithfully. Decimal counts in tens, which is why Trips keeps its prices in one. Build a Decimal from a string, as here: Decimal(0.1) would start from the inexact Double.

Where this comes from: Decimal — Measured with Xcode 27.

Lesson 33 · question 9 of 10 · a property with a default value

let id = UUID() in a Codable struct, and the JSON has an id. Is it read? And with var id = UUID()?

import Foundation

struct Trip: Codable {
    let id = UUID()
    let city: String
}

let trip = try JSONDecoder().decode(Trip.self, from: Data(#"{"id": "8C0A3E1D-6F1B-4D5E-9A2B-3C4D5E6F7A8B", "city": "Seattle"}"#.utf8))
print(trip.id.uuidString == "8C0A3E1D-6F1B-4D5E-9A2B-3C4D5E6F7A8B")
Show the answer

It compiles, with a warning

immutable property will not be decoded because it is declared with an initial value which cannot be overwritten

Not with let: the compiler warns, and the id is a new one — the comparison printed ``.

A let that already has a value can never be assigned again, so the decoder the compiler writes leaves it alone. With var, the JSON's id is read — and the key becomes required: the default is not used when it is missing.

import Foundation

struct Trip: Codable {
    var id = UUID()
    let city: String
}

let withID = try JSONDecoder().decode(Trip.self, from: Data(#"{"id": "8C0A3E1D-6F1B-4D5E-9A2B-3C4D5E6F7A8B", "city": "Seattle"}"#.utf8))
print(withID.id.uuidString == "8C0A3E1D-6F1B-4D5E-9A2B-3C4D5E6F7A8B")
do {
    _ = try JSONDecoder().decode(Trip.self, from: Data(#"{"city": "Seattle"}"#.utf8))
} catch {
    print(error)
}

It printed

Console
true
DecodingError.keyNotFound: Key 'id' not found in keyed decoding container. Debug description: No value associated with key CodingKeys(stringValue: "id", intValue: nil) ("id").

So a default value on a Codable property is not a fallback for missing JSON. For that, make the property optional, or write init(from:) with decodeIfPresent.

Where this comes from: Encoding and Decoding Custom Types — Measured with Xcode 27; the warning is the compiler's own.

Lesson 33 · question 10 of 10 · where the error happened

An itinerary holds an array of stops; the second stop has no nights. Decoding fails. How do you find out which stop, and what was missing?

import Foundation

struct Stop: Decodable {
    let name: String
    let nights: Int
}

struct Itinerary: Decodable {
    let stops: [Stop]
}

do {
    _ = try JSONDecoder().decode(Itinerary.self, from: Data(#"{"stops": [{"name": "Seattle", "nights": 5}, {"name": "Bergen"}]}"#.utf8))
} catch DecodingError.keyNotFound(let key, let context) {
    print("missing:", key.stringValue)
    print("path:", context.codingPath.map(\.stringValue))
} catch {
    print(error)
}
Show the answer

From the error's context. Each DecodingError case carries a context with a codingPath: the keys and indexes from the top of the JSON down to the problem.

It printed

Console
missing: nights
path: ["stops", "Index 1"]

The stops array, the element at index 1, the key nights. Catch the cases you expect — keyNotFound, typeMismatch, valueNotFound, dataCorrupted — and log the path: it turns "the server sent bad data" into a sentence someone can act on.

Where this comes from: DecodingError — Measured with Xcode 27.

Further reading