-
Notifications
You must be signed in to change notification settings - Fork 0
Book 18 Error Handling And Result Type
Part V — Advanced Techniques · Claude's Xcode 26 Swift Bible
← Book-17-Swift-Charts-And-PDFKit · Chapters and Appendices · Book-19-Building-Custom-Views-And-Modifiers →
Claude's Xcode 26 Swift Bible -- Part V: Advanced Techniques
This chapter covers custom error types with enum and the Error protocol; throwing errors from functions with throws and handling them with do / try / catch; the differences between try, try?, and try!; Swift 6 typed throws (throws(MyError)) for declaring exactly which errors can escape a function; and Result<Success, Failure> for callback-era APIs and cross-thread boundaries.
CryoTunes Player's data layer is a Swift package called CryoKit. The architectural rule the package now follows: a layer should only report on what it actually owns and observed. A data layer that calls MusicKit reports MusicKit-shaped errors; it doesn't author UI prose about networking or other domains it has no access to. The app target reads structured error values from the package and decides how to present them, rather than displaying a string the package wrote.
CryoTunes'
ContentView.swiftreads from MusicKit directly for player state; the package surfaces typed error values for the app to format. See the source at github.com/fluhartyml/CryoTunesPlayer, and Source Tour 18 for the architectural walkthrough. The chapter you're reading now is the language-level foundation; that source tour shows the principle in production form.
Swift functions that might fail have two honest ways to say so:
- Return an
Optional-- "here's a value, or nothing." - Throw an error -- "here's a value, or a specific reason for the failure."
Optionals are great when there's only one failure mode and the reason doesn't matter. A dictionary lookup returning nil is fine -- the key either exists or it doesn't.
Errors are better when the caller needs to know why something failed: a file was missing, the network was down, the JSON was malformed. The error itself carries that story.
Any type conforming to the empty Error protocol can be thrown. Enums are the idiomatic choice:
enum ParseError: Error {
case empty
case notANumber(String)
case outOfRange(value: Int, max: Int)
}Associated values carry the details of the failure -- the offending string, the number that was out of range, whatever the caller needs to explain or recover.
For better error messages, conform to LocalizedError:
extension ParseError: LocalizedError {
var errorDescription: String? {
switch self {
case .empty:
return "Input was empty."
case .notANumber(let s):
return "\"\(s)\" isn't a number."
case .outOfRange(let v, let max):
return "\(v) is out of range (max \(max))."
}
}
}Now printing the error or showing it to the user reads as plain prose.
A function that might throw declares it with throws:
func parse(_ input: String, max: Int) throws -> Int {
guard !input.isEmpty else {
throw ParseError.empty
}
guard let n = Int(input) else {
throw ParseError.notANumber(input)
}
guard n <= max else {
throw ParseError.outOfRange(value: n, max: max)
}
return n
}throws is part of the function's type. Callers have to acknowledge it with try or the compiler rejects the call.
Computed properties and initializers can throw too:
init(fromJSON data: Data) throws {
// ...
}
var size: Int {
get throws {
// ...
}
}The default form is do / try / catch:
do {
let value = try parse("42", max: 100)
print("parsed:", value)
} catch ParseError.empty {
print("input was empty")
} catch ParseError.notANumber(let s) {
print("not a number:", s)
} catch {
print("other:", error)
}Catch clauses are patterns, just like case clauses in switch. A plain catch binds the error as error and acts as the catch-all. Swift checks that the do block's errors are all handled (or that the enclosing function also throws).
When you don't care about the reason for failure:
let maybe = try? parse("abc", max: 100) // Optional<Int>, nil on any throwtry? converts the call into an optional. Handy when you were going to check for nil anyway.
When you're absolutely sure the call can't fail (and you want to trap if you're wrong):
let count = try! parse("42", max: 100) // traps if parse throwsUse try! sparingly. It's appropriate for things like reading a file bundled with the app that you know exists. It's not appropriate for user input, network calls, or anything with uncertainty.
A function whose throwing behavior depends on a closure argument uses rethrows:
func retry<T>(_ body: () throws -> T) rethrows -> T {
do { return try body() }
catch { return try body() }
}
let x = retry { 42 } // non-throwing closure: caller doesn't need try
let y = try retry { try parse("7", max: 10) } // throwing closure: caller needs tryrethrows keeps callers free of try when their closure isn't throwing.
Swift 6 lets you declare exactly which error type a function throws:
func parse(_ input: String, max: Int) throws(ParseError) -> Int {
guard !input.isEmpty else { throw .empty }
guard let n = Int(input) else { throw .notANumber(input) }
guard n <= max else { throw .outOfRange(value: n, max: max) }
return n
}Two things that change:
- Inside the function you can write
.emptyinstead ofParseError.empty(Swift infers the type). - The caller catches the concrete type without an
as?cast:
swift do { let v = try parse("42", max: 10) } catch let error: ParseError { // error is ParseError here, no cast needed }
Plain throws is shorthand for throws(any Error) -- "might throw anything." Use typed throws when your function has a fixed, bounded set of failures and you want the compiler to check that callers handle each one.
Result<Success, Failure> is a standard-library enum with two cases:
public enum Result<Success, Failure: Error> {
case success(Success)
case failure(Failure)
}It represents the same information as a throwing function, but as a value you can pass around, store, and hand to a callback. Three common places you reach for it:
Older callback APIs (pre-async/await) use Result as the payload:
func fetch(completion: @escaping (Result<Data, NetworkError>) -> Void) {
// ... some URLSession-style call
}
fetch { result in
switch result {
case .success(let data): print("got", data.count, "bytes")
case .failure(let e): print("failed:", e)
}
}Throwing across a boundary can be awkward because errors don't flow easily across task or queue hops. Wrapping the value in Result turns it into something you can hand across the gap:
let result: Result<Int, Error> = .success(42)
DispatchQueue.main.async {
handle(result) // thread-safe: it's just a value
}Result has two convenience bridges:
// Throwing call → Result
let r = Result { try parse("42", max: 100) }
// Result → throwing
let n = try r.get()Result(_ body:) runs the throwing closure and bundles the outcome; .get() re-throws the error out of the Result.
In modern Swift code, prefer async throws over Result for anything new. async throws reads more naturally:
// Old
func fetch(completion: (Result<Data, Error>) -> Void)
// New
func fetch() async throws -> DataReach for Result when you're bridging to older callback APIs, storing the outcome, or sending it across a boundary where throw is unavailable (closures stored in collections, async callbacks, cross-actor message passing).
import SwiftUI
enum ParseError: LocalizedError {
case empty
case notANumber(String)
case outOfRange(Int)
var errorDescription: String? {
switch self {
case .empty: return "Please enter a number."
case .notANumber(let s): return "\"\(s)\" isn't a number."
case .outOfRange(let n): return "\(n) is too big (max 100)."
}
}
}
func parse(_ raw: String) throws(ParseError) -> Int {
guard !raw.isEmpty else { throw .empty }
guard let n = Int(raw) else { throw .notANumber(raw) }
guard n <= 100 else { throw .outOfRange(n) }
return n
}
struct NumberInput: View {
@State private var input = ""
@State private var result: String = "—"
@State private var error: String?
var body: some View {
Form {
TextField("Enter a number up to 100", text: $input)
.keyboardType(.numberPad)
Button("Parse") {
do {
let n = try parse(input)
result = "Got \(n)"
error = nil
} catch {
error = error.localizedDescription
result = "—"
}
}
if let error {
Label(error, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
} else {
Text(result)
}
}
}
}One throwing function, one typed error, one do / catch, and messages the user can act on — the same pattern scales to larger surfaces.
Errors are behavior that happens in response to bad input; Book 19 is about behavior you ship on purpose: custom views, custom modifiers, and the SwiftUI techniques that let you build your own widgets instead of always reaching for built-ins.
← Book-17-Swift-Charts-And-PDFKit · Chapters and Appendices · Book-19-Building-Custom-Views-And-Modifiers →
Feedback: Found something off? Open an issue · Discuss it · Email Michael
Claude's X26 Swift6 Bible | GPL v3 | Built with Claude by Anthropic | Repo