Error Handling in Swift
Swift supports systematic error handling through the Error protocol, throws/do-catch, and the Result type. Since the compiler enforces error handling, it prevents mistakes like ignoring or overlooking errors.
This post covers everything from defining errors to the Result type and async error handling patterns.
Defining Error Types
In Swift, errors are defined as types that adopt the Error protocol. Using enums groups related errors together, and associated values convey detailed information.
import Foundation
// Error type definition — enum + Error protocol
enum ValidationError: Error, CustomStringConvertible {
case emptyField(name: String)
case tooShort(field: String, minimum: Int, actual: Int)
case tooLong(field: String, maximum: Int, actual: Int)
case invalidFormat(field: String, expected: String)
// User-friendly message
var description: String {
switch self {
case .emptyField(let name):
return "\(name) field cannot be empty."
case .tooShort(let field, let min, let actual):
return "\(field) must be at least \(min) characters. (current: \(actual))"
case .tooLong(let field, let max, let actual):
return "\(field) must be at most \(max) characters. (current: \(actual))"
case .invalidFormat(let field, let expected):
return "\(field) format is invalid. (expected: \(expected))"
}
}
}
enum NetworkError: Error {
case invalidURL(String)
case connectionFailed
case serverError(statusCode: Int, message: String)
case decodingFailed(underlying: Error)
case timeout
}
// Adopting LocalizedError — integration with system error messages
extension NetworkError: LocalizedError {
var errorDescription: String? {
switch self {
case .invalidURL(let url):
return "Invalid URL: \(url)"
case .connectionFailed:
return "Network connection failed."
case .serverError(let code, let msg):
return "Server error [\(code)]: \(msg)"
case .decodingFailed(let error):
return "Data decoding failed: \(error.localizedDescription)"
case .timeout:
return "Request timed out."
}
}
}
// Validation function — throwing errors with throws
func validateUsername(_ username: String) throws {
guard !username.isEmpty else {
throw ValidationError.emptyField(name: "Username")
}
guard username.count >= 3 else {
throw ValidationError.tooShort(field: "Username", minimum: 3, actual: username.count)
}
guard username.count <= 20 else {
throw ValidationError.tooLong(field: "Username", maximum: 20, actual: username.count)
}
}
// Handling errors with do-catch
func registerUser(username: String) {
do {
try validateUsername(username)
print("Registration successful: \(username)")
} catch let error as ValidationError {
print("Validation failed: \(error)")
} catch {
print("Unknown error: \(error)")
}
}
registerUser(username: "John")
// Output: Registration successful: John
registerUser(username: "ab")
// Output: Validation failed: Username must be at least 3 characters. (current: 2)
registerUser(username: "")
// Output: Validation failed: Username field cannot be empty.
Differences Between try, try?, and try!
Swift provides three try variants, each with different error handling behaviors.
import Foundation
enum ParseError: Error {
case invalidInput(String)
}
func parseInt(_ str: String) throws -> Int {
guard let value = Int(str) else {
throw ParseError.invalidInput(str)
}
return value
}
// try — requires a do-catch block
func demonstrateTry() {
// try — handle errors with catch
do {
let num = try parseInt("42")
print("try result: \(num)")
} catch {
print("Error: \(error)")
}
// Output: try result: 42
// try? — returns nil on error (Optional)
let result1: Int? = try? parseInt("42")
let result2: Int? = try? parseInt("abc")
print("try? success: \(result1 ?? -1)") // 42
print("try? failure: \(result2 ?? -1)") // -1
// Output:
// try? success: 42
// try? failure: -1
// try! — runtime crash on error (use only when certain!)
let guaranteed = try! parseInt("100")
print("try! result: \(guaranteed)")
// Output: try! result: 100
// try! parseInt("abc") // Runtime crash!
// try? combined with nil coalescing — default value pattern
let port = (try? parseInt("8080")) ?? 3000
print("Port: \(port)")
// Output: Port: 8080
let fallback = (try? parseInt("invalid")) ?? 3000
print("Fallback port: \(fallback)")
// Output: Fallback port: 3000
}
demonstrateTry()
try? is handy when you don’t need error details. try! should only be used when you’re absolutely certain it won’t fail, and it’s best avoided whenever possible.
Result Type — Explicit Success and Failure
The Result type wraps both the success value and the error into a single type. It’s useful for callback-based APIs and deferred error handling.
import Foundation
struct User {
let id: Int
let name: String
let email: String
}
enum APIError: Error, CustomStringConvertible {
case notFound(id: Int)
case unauthorized
case rateLimited(retryAfter: Int)
var description: String {
switch self {
case .notFound(let id): return "ID \(id) not found"
case .unauthorized: return "Authentication required"
case .rateLimited(let sec): return "Retry after \(sec) seconds"
}
}
}
// Returning a Result type
func fetchUser(id: Int) -> Result<User, APIError> {
guard id > 0 else {
return .failure(.notFound(id: id))
}
guard id != 999 else {
return .failure(.unauthorized)
}
return .success(User(id: id, name: "User_\(id)", email: "user\(id)@example.com"))
}
// Result usage patterns
func processUser(id: Int) {
let result = fetchUser(id: id)
// Handle with switch
switch result {
case .success(let user):
print("User: \(user.name) (\(user.email))")
case .failure(let error):
print("Error: \(error)")
}
}
processUser(id: 1)
// Output: User: User_1 (user1@example.com)
processUser(id: 0)
// Output: Error: ID 0 not found
processUser(id: 999)
// Output: Error: Authentication required
// Functional methods on Result — map, flatMap, mapError
func enrichUser(id: Int) {
let displayName = fetchUser(id: id)
.map { "\($0.name) <\($0.email)>" } // Transform success value
switch displayName {
case .success(let name):
print("Display name: \(name)")
case .failure(let error):
print("Failed: \(error)")
}
}
enrichUser(id: 5)
// Output: Display name: User_5 <user5@example.com>
// Converting Result to throws
func getUser(id: Int) throws -> User {
return try fetchUser(id: id).get() // Throws on failure
}
do {
let user = try getUser(id: 3)
print("get() result: \(user.name)")
} catch {
print("get() error: \(error)")
}
// Output: get() result: User_3
rethrows and Error Propagation
rethrows is a conditional throws — the function only throws if the closure passed to it throws. Standard library functions like map and filter use this pattern.
import Foundation
// rethrows — function throws only when the closure throws
func retry<T>(
times: Int,
delay: TimeInterval = 1.0,
operation: () throws -> T
) rethrows -> T {
var lastError: Error?
for attempt in 1...times {
do {
return try operation()
} catch {
lastError = error
print("Attempt \(attempt)/\(times) failed: \(error)")
if attempt < times {
Thread.sleep(forTimeInterval: delay)
}
}
}
throw lastError!
}
// Error propagation example
var callCount = 0
func unstableOperation() throws -> String {
callCount += 1
if callCount < 3 {
throw NSError(domain: "test", code: callCount, userInfo: [
NSLocalizedDescriptionKey: "Temporary error #\(callCount)"
])
}
return "Success (attempt \(callCount))"
}
do {
let result = try retry(times: 5, delay: 0.1) {
try unstableOperation()
}
print("Final result: \(result)")
} catch {
print("All attempts failed: \(error)")
}
// Output:
// Attempt 1/5 failed: Temporary error #1
// Attempt 2/5 failed: Temporary error #2
// Final result: Success (attempt 3)
// defer — always executes when scope exits
func processFile(path: String) throws {
print("Opening file: \(path)")
defer {
print("Closing file: \(path)") // Runs regardless of whether an error occurs
}
// Perform work...
print("Processing file...")
// throw SomeError // defer block still runs even if an error is thrown
print("File processing complete")
}
try processFile(path: "/tmp/data.txt")
// Output:
// Opening file: /tmp/data.txt
// Processing file...
// File processing complete
// Closing file: /tmp/data.txt
Practical Tips
- Define specific error types: Define domain-specific errors as enums adopting the
Errorprotocol. Use associated values to convey contextual information. - Adopt LocalizedError: When you need user-facing error messages, adopt
LocalizedErrorand implementerrorDescription. - The try? + ?? pattern: When error details are unnecessary, use
(try? operation()) ?? defaultValue. - Result is useful for callbacks: In completion handler-based APIs, use
Resultto convey success/failure. If async/await is available, use throws directly instead. - Clean up with defer: Use
deferblocks to release resources like file handles and network connections that must always be cleaned up. - Avoid try!: In production code,
try!causes runtime crashes. Use it only sparingly in test code.