Throwing, trapping, and choosing between them
Swift has four distinct mechanisms for things going wrong, and using the wrong one is how a recoverable condition turns into a crash report.
| Mechanism | For |
|---|---|
throws |
Expected failures the caller can handle: bad input, missing file, network refused |
Optional |
Absence that carries no explanation: no such key, no first element |
precondition / fatalError |
Programmer error: an invariant your code guarantees was violated |
assert |
Same, but debug builds only |
The line worth drawing: if the failure is caused by the world, throw. If it is caused by the programmer, trap. A parser given malformed input should throw; a parser that finds its own internal offset out of range should trap, because there is no correct recovery from a broken invariant.
Error has no requirements. Any type can be one, and enums are the natural fit:
enum LoadError: Error {
case notFound(path: String)
case corrupted(offset: Int)
case tooLarge(bytes: Int, limit: Int)
}
Attach the context at the throw site, where it is still known. An error that says .corrupted and
nothing else forces every layer above to guess.
For anything a user might see, add LocalizedError:
extension LoadError: LocalizedError {
var errorDescription: String? {
switch self {
case .notFound(let path): "No file at \(path)."
case .corrupted(let offset): "The file is damaged at byte \(offset)."
case .tooLarge(let bytes, let limit): "\(bytes) bytes exceeds the \(limit) byte limit."
}
}
}
Until recently every throws meant “throws something, good luck”. Swift 6 lets you name it:
func load(_ path: String) throws(LoadError) -> Data
Now the compiler knows the full set of failures, catch can be exhaustive without a default, and
the error can be a frozen enum in an embedded context with no existential box at all.
Use it where the error set is genuinely closed and part of the contract — a parser, a decoder, a
small library. Avoid it on API boundaries that will grow: adding a case to a typed-throws function is
a source-breaking change for every exhaustive catch, which is the same trap as a non-frozen enum.
Untyped throws is still the right default for application code.
rethrows says “I only throw if the closure you gave me throws”:
extension Sequence {
func count(where predicate: (Element) throws -> Bool) rethrows -> Int {
var total = 0
for element in self where try predicate(element) { total += 1 }
return total
}
}
Callers passing a non-throwing closure do not need try. Without rethrows, every caller of every
higher-order function would be forced into error handling it does not need.
defer runs on the way out of scope, however that happens — return, throw, or falling off the end:
func process(_ path: String) throws -> Report {
let handle = try FileHandle(forReadingFrom: URL(filePath: path))
defer { try? handle.close() }
let header = try readHeader(handle)
guard header.isSupported else { throw LoadError.corrupted(offset: 0) }
return try buildReport(from: handle, header: header)
}
The close is written next to the open, and there is exactly one of it no matter how many exits the
function grows later. Multiple defer blocks run in reverse order, which matches how resources
nest.
Warning
defer captures values as they are at the moment the block runs, not when it is written. A
defer { print(count) } at the top of a function prints the final count, not zero. This is
almost always what you want and occasionally very confusing.
Result<Success, Failure> turns an error into a value you can store, pass around, or hand to a
completion handler. With async/await covering most callback APIs, its remaining uses are narrow:
caching a failure, collecting outcomes from a batch, or crossing an API that cannot be async.
let outcomes: [Result<Data, LoadError>] = paths.map { path in
Result { try load(path) }.mapError { $0 as! LoadError }
}
If you find yourself converting Result back to throws immediately with try result.get(), the
function probably wanted to be throws in the first place.