9. Errors
Swift asks you to sort failures into three kinds and gives each a different mechanism. Getting the sorting right is most of the work; the syntax takes ten minutes.
A value that might not be there is an Optional. There is no error, nothing
went wrong, and a dictionary with no entry for a key is behaving correctly.
Chapter 5 covered these.
A thing that can fail for reasons outside your control is an error: the file
is missing, the network is down, the input is malformed. The caller can
reasonably do something about it, so the function throws and the caller
decides.
A thing that cannot fail unless the program is wrong is a bug. An index past the end of an array, an arithmetic overflow, a force unwrap of nothing. These stop the program, and they should, because continuing means running code whose assumptions have already been proven false.
Do not use the second mechanism for the third. A function that throws
invalidIndex is asking every caller to handle a case that means somebody
upstream made a mistake, and none of them can fix it.
9.1 Throwing and Catching
An error is any value whose type conforms to Error, which is an empty
protocol. An enum is the usual choice, because a function’s failures are a fixed
set of cases with values attached, which is the shape Chapter 8 is about.
// An error is a value of a type you define, thrown and caught by pattern.
enum ConfigError: Error {
case missingKey(String)
case notANumber(key: String, value: String)
case outOfRange(key: String, value: Int, allowed: ClosedRange<Int>)
}
let settings = ["port": "8080", "retries": "many", "timeout": "30"]
func intValue(for key: String, in range: ClosedRange<Int>) throws -> Int {
guard let text = settings[key] else {
throw ConfigError.missingKey(key)
}
guard let n = Int(text) else {
throw ConfigError.notANumber(key: key, value: text)
}
guard range.contains(n) else {
throw ConfigError.outOfRange(key: key, value: n, allowed: range)
}
return n
}
func report(_ key: String) -> String {
do {
return "\(key) = \(try intValue(for: key, in: 1...600))"
} catch ConfigError.missingKey(let key) {
return "\(key) is not set"
} catch ConfigError.notANumber(let key, let value) {
return "\(key) should be a number, not \"\(value)\""
} catch ConfigError.outOfRange(let key, let value, let allowed) {
return "\(key) is \(value), outside \(allowed.lowerBound)...\(allowed.upperBound)"
} catch {
return "\(key) failed: \(error)"
}
}
for key in ["port", "retries", "timeout", "host"] {
print(report(key))
}
// try? turns a thrown error into nothing, when you do not care which it was.
let port = (try? intValue(for: "port", in: 1...99999)) ?? 80
print("port is \(port)")
port is 8080, outside 1...600
retries should be a number, not "many"
timeout = 30
host is not set
port is 8080
throws goes before the arrow and is part of the function’s type, so it is
visible at every call. A call to a throwing function must be marked try, and
the compiler insists:
missing_try.swift:7:9: error: call can throw but is not marked with 'try'
7 | let n = risky()
| |- note: did you mean to use 'try'?
| |- note: did you mean to handle error as optional value?
| `- note: did you mean to disable error propagation?
The three notes are the three answers: try and let it propagate, try? and
treat failure as nothing, try! and crash if it happens. try is not a block
and does not catch anything; it marks the call so a reader scanning the function
can see every place control might leave.
catch clauses are patterns, the same patterns as a switch arm, so a thrown
enum can be taken apart with its associated values in the catch itself. A bare
catch matches anything and binds the error to a name called error without
your declaring it. Catches must be exhaustive, which means a bare catch at the
end unless the enclosing function itself throws, in which case anything unhandled
propagates.
Propagation is the default and the reason this design is worth having. A
function that calls three throwing functions and handles none of them writes
try three times and says throws once, and the error travels to whoever is in
a position to do something about it. Compare a return code, which every layer in
between must check and forward by hand, and which any layer may quietly drop.
9.2 defer
defer schedules a block to run when the current scope exits, however it exits:
a return, a throw, a break, or falling off the end.
// defer runs on the way out, however the way out happens.
enum Trouble: Error { case badLine(Int) }
func process(_ lines: [String]) -> String {
print("open")
defer { print("close") }
do {
for (i, line) in lines.enumerated() {
defer { print(" done with line \(i)") }
print(" line \(i): \(line)")
if line.isEmpty {
throw Trouble.badLine(i)
}
}
return "all \(lines.count) lines"
} catch {
return "stopped: \(error)"
}
}
print(process(["a", "b"]))
print("---")
print(process(["a", "", "c"]))
open
line 0: a
done with line 0
line 1: b
done with line 1
close
all 2 lines
---
open
line 0: a
done with line 0
line 1:
done with line 1
close
stopped: badLine(1)
The cleanup sits next to the thing it cleans up rather than at the bottom of the
function in every exit path, which is the point. The second run shows it working
on the way out of a throw: the line’s defer still runs, and so does the
function’s.
Two rules. Several defers in one scope run in reverse order, last registered
first, which is right when the second depends on the first. And the scope is the
enclosing braces, not the function, so the defer inside the loop body runs on
every turn.
defer is for releasing something you acquired: closing a file, unlocking a
lock, restoring a setting. It is not a place to put the second half of a
function.
9.3 Result
Result<Success, Failure> is an error stored as a value rather than thrown. It
is what you want when the failure has to be carried somewhere before anybody
looks at it: across a callback, into an array, out of a batch of operations
where each one may fail separately.
// Result is the error as a value, for when you cannot throw at the call.
enum Trouble: Error, CustomStringConvertible {
case tooShort(String)
var description: String {
switch self {
case .tooShort(let s): return "\"\(s)\" is too short"
}
}
}
func check(_ s: String) throws -> String {
guard s.count >= 3 else { throw Trouble.tooShort(s) }
return s.uppercased()
}
let outcomes = ["ada", "x", "grace"].map { name in
Result { try check(name) }
}
for outcome in outcomes {
switch outcome {
case .success(let s): print("ok", s)
case .failure(let e): print("no", e)
}
}
// A Result can be transformed without being opened.
let lengths = outcomes.map { $0.map(\.count) }
print(lengths.map { (try? $0.get()) ?? -1 })
// Typed throws names the one error a function can produce.
func strict(_ s: String) throws(Trouble) -> String {
guard s.count >= 3 else { throw .tooShort(s) }
return s
}
do {
print(try strict("hi"))
} catch {
// error is a Trouble here, not any Error
print("caught a \(type(of: error)): \(error)")
}
ok ADA
no "x" is too short
ok GRACE
[3, -1, 5]
caught a Trouble: "hi" is too short
Result { try something() } runs the closure and captures whichever way it
went, which is the bridge from throwing code into stored results. get() is the
bridge back: it returns the value or throws the error, so try result.get()
puts you back in the throwing world.
Result is an enum with two cases, so it switches like one, and map transforms
the success while leaving a failure alone, the way Optional.map does. \.count
is a key path, which is a lightweight way of writing { $0.count }.
Conforming the error type to CustomStringConvertible is worth doing whenever a
human will read the message. Without it, printing an error gives you the case
name and its payload, which is fine for a log and poor in a message to a user.
throws(Trouble) is typed throws: the function declares the one error type it
can produce, so the catch gets a Trouble rather than an Error and can
switch over its cases exhaustively without a default. Use it where the set of
failures is genuinely closed and part of the contract. Plain throws means
throws(any Error) and stays the right default for anything that calls into
code you do not control.
9.4 Stopping
When the program is wrong, stop it. fatalError("message") always does.
precondition(condition, "message") checks a condition that must hold, in
release builds as well as debug. assert checks one only in debug builds, for
things too expensive to verify in production.
The choice between precondition and assert is about what a wrong answer
costs against what the check costs. Validating an index is cheap and belongs in
both; verifying that a list of a million items is sorted is not.
A function that can never return is marked -> Never, which tells the compiler
that a guard else calling it is a legitimate exit.
Exercise 9-1. Give ConfigError a CustomStringConvertible conformance and
delete the pattern-matching catch clauses, keeping only the bare one. Decide
which version you would rather maintain.
Exercise 9-2. Write a function that reads a file of numbers and returns
[Result<Int, Trouble>], one entry per line, then a second function that turns
that into either all the numbers or the first error.
Exercise 9-3. Add a defer to intValue(for:in:) that logs the key on the
way out, and confirm it fires on the throwing paths.
Exercise 9-4. Find out what rethrows means and write a function that needs
it.