When should an API trap with precondition, return nil, or throw: programmer error versus expected failure?
Predict first
Both initialisers on one type, with values from inside and outside the program:
struct Percent {
let value: Int
init(_ value: Int) { // programmer error: the caller can check the range
precondition((0...100).contains(value), "Percent must be 0...100, got \(value)")
self.value = value
}
init?(validating value: Int) { // data from outside: failure is expected
guard (0...100).contains(value) else { return nil }
self.value = value
}
}
print(Percent(40).value, Percent(validating: 140)?.value as Any, Percent(validating: 60)?.value as Any)
- 40 nil Optional(60)
- It traps on 140
- 40 Optional(140) Optional(60)
Predict first
The trapping initialiser receives 140 from a server:
struct Percent {
let value: Int
init(_ value: Int) { precondition((0...100).contains(value), "Percent must be 0...100, got \(value)"); self.value = value }
}
let fromServer = 140
print(Percent(fromServer).value)
- The process terminates with a precondition failure
- It prints 140
- It returns nil
Mental model. A pilot’s checklist stops the flight when the plane itself is wrong (trap), but turbulence is expected and handled in flight (throw). You do not write a recovery procedure for “forgot the wings”.
Pick by who could have prevented the failure and whether the caller can reasonably recover. A violated precondition that the caller controls and could have checked (an index outside an array, a percentage of 140 passed to an initialiser documented as 0…100) is a programmer error: precondition traps with a clear message, the bug is found at the first call in testing, and the API stays free of error handling that nobody can sensibly do. Failure that is a normal outcome of dealing with outside data (user typing “abc” into a number field, a server returning 140) should be expected: a failable initialiser returning nil when there is exactly one way to fail and nothing to explain, or a throws function (usually with a typed or documented error) when the caller needs the reason. The check offers both on one type: Percent(40) traps outside the range, Percent(validating:) returns nil for 140 and a value for 60. Feeding server data to the trapping initialiser (second check) terminates the process with “Precondition failed”.
Practical rules. Offer both forms when callers have both situations, name the checking one clearly (validating:, exactly:, init?(rawValue:)), and document the precondition in the doc comment. precondition stays in release builds, assert is removed (Q459 compares them), and fatalError is for unreachable states. Do not trap on anything an external party can cause: input data, files, network, user configuration; a crash there is a denial of service you wrote yourself. Conversely do not throw for programmer errors: forcing every caller into try for a bug turns a loud failure into a handled one that hides the mistake. Decide at design time, because changing the form later breaks callers: adding throws to an existing function is a source break for callers in non-throwing contexts.
The validating initialiser reports expected failure as nil.
A precondition violation is fatal in release builds too.
Common traps
- Trapping on external data. One malformed response then crashes every user who receives it.
- Throwing for what is plainly a caller bug. It hides the defect behind generic error handling.
They might follow up with
- Why
init?and notthrows? Use nil when there is one obvious failure; throw when callers need to distinguish reasons. - Do preconditions survive in release builds?
preconditiondoes in-O; it is removed only in-Ounchecked.
Related questions
- 15.10.3 How do you design the error type of a library: stable cases, underlying errors, LocalizedError, and bridging…
- 15.10.1 How do phantom-typed identifiers stop an order id being passed where a user id is expected, and what do they…
- 15.4.1 How do you model errors in Swift: throws, Result, optionals and typed throws, and how do you translate them…
- 23.3.2 Why do some bugs appear only in Release builds, how do assert, precondition and fatalError differ, and how do…
← How do you design the error type of a library: stable cases…