Skip to content
C.W.K.
Stream
Lesson 04 of 06 · published

Errors That Say What Went Wrong

~15 min · swift-for-apple, errors, typed-throws, objc-exceptions

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Skipping the unreadable item was right. Dropping the count was the bug."

Swift Errors Are Values You Can Design

A Swift function that can fail says throws, and the caller must try. Since Swift 6 you can also write typed throwsthrows(InboxError) — so the caller knows the exact set of failures without casting. An error type is a place to put the words a person needs: which file, which entitlement, which remedy. CustomStringConvertible or LocalizedError turns that into a message you can show.

try? is the escape hatch that converts any error into nil. It belongs where failure genuinely does not matter. Everywhere else it is the error-handling twin of the quiet else { return } from the previous lesson.

The Queue That Reported Itself Empty

A per-item capture queue listed its folder like this: decode each manifest, and continue past any that failed. Keep reading past a bad item — that part was right; it is what a folder-per-item store buys you. But a manifest that would not decode did not fail: it became invisible. The files stayed on disk, every count said the item was not there, nothing logged, and the app showed an empty queue.

It was not hypothetical. The first time the item type gains a field, every manifest written before that moment stops decoding at once (the next lesson shows why). On a phone where that queue is the only copy of captures made with no network, an app update would have emptied it and said nothing. The fix kept skipping and stopped hiding: the listing returns the readable items and the unreadable ones with a reason each, and the app shows "N captures this build cannot read — still on this device, untouched".

The Share Extension That Always Succeeded

A share extension saved with try? and then called completeRequest no matter what happened. Every failure — a file that would not load, a folder that was missing — reported success to iOS and to the user. The rule the family took from it: an extension must not be able to fail silently in any direction. A refusal is visible in the sheet, an error is an alert, a hang is a timeout that becomes an alert.

Some Failures Swift Cannot Catch

Swift do/catch handles Swift errors. It does not catch an Objective-C NSException. A family dictation app called AVAudioEngine.prepare() before the engine had any tap installed; on macOS 26 that raises an exception inside AVFoundation, and the app died with SIGABRT about half a second after launch. No catch could have helped. The defence is calling Objective-C APIs in the order and state their documentation requires.

Code

Typed throws, and a listing that cannot hide what it skipped·swift
import Foundation

struct Capture: Codable { let id: String; var text: String }

enum InboxError: Error, CustomStringConvertible {
    case containerMissing(group: String)
    case unreadable(file: String, reason: String)

    var description: String {
        switch self {
        case .containerMissing(let group):
            return "App Group container \(group) is not reachable — check the entitlement"
        case .unreadable(let file, let reason):
            return "\(file) could not be read: \(reason)"
        }
    }
}

func requireContainer(_ url: URL?, group: String) throws(InboxError) -> URL {
    guard let url else { throw .containerMissing(group: group) }
    return url
}

struct Listing<Item> {
    var items: [Item]
    var unreadable: [InboxError]      // the caller cannot accidentally not look
}

func listCaptures(in folder: URL) throws(InboxError) -> Listing<Capture> {
    let files: [URL]
    do {
        files = try FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)
    } catch {
        // A folder that cannot be listed is not an empty inbox.
        throw .unreadable(file: folder.lastPathComponent, reason: "\(error)")
    }
    var listing = Listing<Capture>(items: [], unreadable: [])
    for file in files where file.pathExtension == "json" {
        do {
            listing.items.append(try JSONDecoder().decode(Capture.self, from: Data(contentsOf: file)))
        } catch {
            listing.unreadable.append(.unreadable(file: file.lastPathComponent, reason: "\(error)"))
        }
    }
    return listing
}

External links

Exercise

Give Spark a CaptureStore whose list() returns both readable captures and unreadable files with reasons. Put three JSON files in a folder, corrupt one by hand, and show that the listing reports two items and one named failure. Then write the one line of UI text the app would show for that failure.
Hint
Make the unreadable list part of the return type, not a log line. The whole point is that a caller who only wanted the items still has to hold the failures in their hand.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.