Error handling
Swift’s error-handling model is typed and explicit — errors are values conforming to the Error protocol; functions that may throw are marked throws; calling a throwing function requires try. The principal forms: do/catch/throw for exception-style flow; try? for “convert to optional”; try! for “trap on error” (force-unwrap analogue); Result<Success, Failure> for value-style error handling. The conventional discipline is throws for the principal mechanism — Swift’s typed errors admit substantial flexibility without runtime exception-unwinding cost. The combination — typed errors via the Error protocol, the throws modifier, try/do/catch, the Result value type, the defer for cleanup — is the substance of Swift’s error-handling surface.
The Error protocol
The Error protocol marks types as errors:
enum ParseError: Error {
case empty
case invalid(reason: String)
case tooLong(count: Int)
}
struct NetworkError: Error {
let code: Int
let message: String
}
Any type may conform; enums are conventional for distinct error variants, structs for substantial error data.
throws and throw
A function that may throw is marked throws:
enum ParseError: Error {
case empty
case invalid(reason: String)
}
func parse(_ input: String) throws -> Int {
guard !input.isEmpty else {
throw ParseError.empty
}
guard let n = Int(input) else {
throw ParseError.invalid(reason: "not numeric")
}
return n
}
Calling a throwing function requires try:
do {
let n = try parse("42")
print(n)
} catch {
print("error: \(error)")
}
do/catch
The conventional handler form:
do {
let n = try parse(input)
let doubled = try compute(n)
print(doubled)
} catch ParseError.empty {
print("input was empty")
} catch ParseError.invalid(let reason) {
print("invalid: \(reason)")
} catch {
print("other: \(error)")
}
Multiple catch clauses admit type-based dispatch via pattern matching — the same pattern grammar as switch.
The error is the implicit binding in a bare catch:
do {
try riskyOperation()
} catch {
print("caught: \(error)")
}
Equivalent to:
do {
try riskyOperation()
} catch let error {
print("caught: \(error)")
}
try?
The try? admits “convert throw to nil”:
let n = try? parse("42") // Optional<Int>; nil on error
let n = try? parse("abc") // nil
if let n = try? parse(input) {
process(n)
}
The conventional uses are when error details are not needed; loses substantial error information.
try!
The try! admits “trap on error”:
let n = try! parse("42") // Int (crashes on error)
let url = try! URL.parse("https://example.com") // when failure is impossible
The conventional discipline avoids try! — admit only when the error cannot reasonably occur. The crash on error is runtime trap; analogous to force-unwrap (!) on optionals.
Result type
The Result<Success, Failure> admits value-style error handling:
enum FetchError: Error {
case network(URLError)
case decoding(DecodingError)
case timeout
}
func fetch() async -> Result<Data, FetchError> {
do {
let (data, _) = try await URLSession.shared.data(from: url)
return .success(data)
} catch let error as URLError {
return .failure(.network(error))
} catch {
return .failure(.timeout)
}
}
let result = await fetch()
switch result {
case .success(let data):
process(data)
case .failure(.network(let urlError)):
handle(urlError)
case .failure(.timeout):
retry()
default:
fallback()
}
The Result is conventional in callback-based APIs and substantial composition; for async/await, throwing is typically conventional.
Result.get()
Convert a Result to a throwing call:
let result: Result<Int, MyError> = .success(42)
do {
let value = try result.get() // unwraps success or throws
print(value)
} catch {
print(error)
}
Conversion between throws and Result
// Throws to Result:
let result = Result { try parse(input) }
// Result to throws:
let value = try result.get()
The mechanisms admit substantial composition between the two paradigms.
defer
The defer admits deferred execution at scope exit:
func processFile(path: String) throws -> String {
let file = try FileHandle(forReadingFrom: URL(fileURLWithPath: path))
defer { file.closeFile() } // runs at scope exit, even on throw
return try String(contentsOf: file)
}
The defer is the conventional Swift form for cleanup; admits substantial linear code paths with explicit cleanup.
Multiple defers run in reverse order (LIFO):
func example() {
defer { print("first deferred") }
defer { print("second deferred") }
defer { print("third deferred") }
print("end")
}
// Output:
// end
// third deferred
// second deferred
// first deferred
rethrows
A function that re-throws errors from a closure parameter:
func map<T, U>(_ array: [T], transform: (T) throws -> U) rethrows -> [U] {
var result: [U] = []
for item in array {
result.append(try transform(item)) // rethrows the closure's error
}
return result
}
// Calling with a throwing closure requires try:
do {
let parsed = try map(strings) { try parse($0) }
} catch { /* ... */ }
// Calling with a non-throwing closure does not:
let doubled = map([1, 2, 3]) { $0 * 2 } // no try needed
The rethrows admits the function being throwing or non-throwing depending on the closure’s behaviour.
Custom Error types
Enum errors
enum AuthError: Error {
case notAuthenticated
case insufficientPermissions(required: [Permission])
case sessionExpired(at: Date)
var localizedDescription: String {
switch self {
case .notAuthenticated:
return "User is not authenticated"
case .insufficientPermissions(let required):
return "Missing permissions: \(required)"
case .sessionExpired(let date):
return "Session expired at \(date)"
}
}
}
Struct errors
struct ValidationError: Error {
let field: String
let message: String
let underlying: Error?
init(field: String, message: String, underlying: Error? = nil) {
self.field = field
self.message = message
self.underlying = underlying
}
}
Conforming to LocalizedError
For substantial human-readable messages:
enum AuthError: LocalizedError {
case notAuthenticated
case insufficientPermissions
var errorDescription: String? {
switch self {
case .notAuthenticated:
return "You must log in"
case .insufficientPermissions:
return "You don't have permission"
}
}
var failureReason: String? {
switch self {
case .notAuthenticated:
return "No active session"
case .insufficientPermissions:
return "Account lacks required role"
}
}
}
The LocalizedError admits substantial integration with system localisation.
Async errors
async functions integrate seamlessly with throws:
func fetch(_ url: URL) async throws -> Data {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw FetchError.invalidResponse
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw FetchError.statusCode(httpResponse.statusCode)
}
return data
}
// Calling:
do {
let data = try await fetch(url)
process(data)
} catch FetchError.statusCode(404) {
notFound()
} catch {
log(error)
}
Treated in Concurrency.
Common patterns
Validate-and-throw
func validate(user: User) throws {
guard !user.name.isEmpty else {
throw ValidationError.emptyName
}
guard user.age >= 0 && user.age <= 150 else {
throw ValidationError.invalidAge(user.age)
}
guard user.email.contains("@") else {
throw ValidationError.invalidEmail(user.email)
}
}
Wrap external errors
func loadConfig() throws -> Config {
do {
let data = try Data(contentsOf: configURL)
return try JSONDecoder().decode(Config.self, from: data)
} catch let error as DecodingError {
throw ConfigError.invalidFormat(underlying: error)
} catch {
throw ConfigError.loadFailed(underlying: error)
}
}
Try-with-default
func fetchOrDefault() -> Data {
return (try? fetch()) ?? Data()
}
// Or:
func fetchOrDefault() -> Data {
do {
return try fetch()
} catch {
log(error)
return Data()
}
}
Resource cleanup with defer
func processFile(at path: String) throws -> ProcessResult {
let file = try open(path)
defer { close(file) } // always runs
let lock = try acquireLock(file)
defer { releaseLock(lock) } // always runs
return try process(file)
}
Retry with substantial logic
func with<T>(retries: Int, _ operation: () async throws -> T) async throws -> T {
var lastError: Error?
for attempt in 0..<retries {
do {
return try await operation()
} catch let error as RetryableError {
lastError = error
if attempt < retries - 1 {
try await Task.sleep(for: .seconds(pow(2.0, Double(attempt))))
}
}
}
throw lastError!
}
Result for callback APIs
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(FetchError.noData))
return
}
completion(.success(data))
}.resume()
}
In modern Swift, async/await is conventionally preferred over callback-based APIs.
Type-based catch chains
do {
try operation()
} catch let error as NetworkError {
handleNetwork(error)
} catch let error as ValidationError {
handleValidation(error)
} catch let error as DecodingError {
handleDecoding(error)
} catch {
log("unexpected: \(error)")
}
Pattern matching in catch
do {
try fetch()
} catch FetchError.statusCode(let code) where (400..<500).contains(code) {
handleClientError(code)
} catch FetchError.statusCode(let code) where (500..<600).contains(code) {
handleServerError(code)
} catch {
log(error)
}
The catch admits the substantial pattern grammar.
Throwing function as parameter
extension Sequence {
func firstResult<T>(_ transform: (Element) throws -> T) rethrows -> T? {
for item in self {
return try transform(item)
}
return nil
}
}
let result = try [1, 2, 3].firstResult { try parse(String($0)) }
The rethrows admits substantial flexibility.
Multiple errors with TaskGroup
func fetchAll(urls: [URL]) async throws -> [Data] {
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask { try await fetch(url) }
}
var results: [Data] = []
for try await data in group { // throws on first failure
results.append(data)
}
return results
}
}
Treated in Concurrency.
precondition and assert
For programmer-error checks (not user-facing errors):
func divide(_ a: Int, _ b: Int) -> Int {
precondition(b != 0, "division by zero") // runtime check
return a / b
}
func processIndex(_ i: Int, in arr: [Int]) -> Int {
assert(i < arr.count) // debug-only check
return arr[i]
}
The precondition runs in all builds; assert runs only in debug.
For unrecoverable errors:
func nextID() -> ID {
guard let id = generateID() else {
fatalError("ID generation failed") // crashes; runs in all builds
}
return id
}
Error wrapping with cause
struct WrappedError: Error {
let context: String
let underlying: Error
}
func loadConfig() throws -> Config {
do {
let data = try Data(contentsOf: configURL)
return try JSONDecoder().decode(Config.self, from: data)
} catch {
throw WrappedError(context: "loading config from \(configURL)", underlying: error)
}
}
try? for “best effort”
func loadCachedOrFetch() async -> Data {
if let cached = try? loadCache() {
return cached
}
return (try? await fetch()) ?? Data()
}
Throwing initialiser
struct User: Codable {
let id: Int
let name: String
init(from data: Data) throws {
self = try JSONDecoder().decode(User.self, from: data)
}
}
let user = try User(from: jsonData)
The init throws admits substantial conventional construction with substantial error reporting.
A note on Result vs throws
The two complement each other:
| Approach | Use when |
|---|---|
throws | Synchronous code with substantial composition |
async throws | Asynchronous code |
Result<S, F> | Callback-based APIs; storing for later handling |
Generally, throws is the conventional Swift form; Result is conventional in callback patterns and when the error must be stored or composed.
A note on the conventional discipline
The contemporary Swift error-handling advice:
- Conform error types to
Error— typically enums for distinct variants. - Use
throwsfor fallible synchronous functions. - Use
async throwsfor fallible asynchronous functions. - Use
do/catchwith type-based dispatch. - Use
try?when error details are not needed. - Avoid
try!— admit only when failure is impossible. - Use
Resultfor callback patterns and substantial composition. - Use
deferfor resource cleanup. - Use
rethrowsfor higher-order functions taking throwing closures. - Use
LocalizedErrorfor user-facing error messages. - Use
precondition/assert/fatalErrorfor programmer errors (not user errors). - Wrap external errors with substantial context.
The combination — the Error protocol, the throws modifier, try/do/catch, the Result value type, defer for cleanup, rethrows for higher-order functions — is the substance of Swift’s error-handling surface. The discipline produces explicit, type-safe, well-documented error flows with substantial flexibility for substantial error-handling patterns.