Skip to content

Error Handling | Swift - Wyatt's Notes

Errors in Swift are represented by types conforming to the Error protocol, which is an empty protocol. Any type can be an error.

enum NetworkError: Error {
case invalidURL
case noConnection
case timeout
case serverError(statusCode: Int)
case decodingFailed
}
enum ValidationError: Error {
case emptyField(String)
case invalidEmail(String)
case passwordTooShort(minLength: Int)
case ageOutOfRange(min: Int, max: Int)
}
enum FileError: Error {
case notFound(path: String)
case permissionDenied(path: String)
case unreadable
}
struct AppError: Error {
let code: Int
let message: String
let underlyingError: Error?
var localizedDescription: String {
return "[\(code)] \(message)"
}
}

Functions marked with throws propagate errors to the caller.

func fetchUser(id: Int) throws -> User {
guard id > 0 else {
throw ValidationError.emptyField("User ID")
}
// ... network call
return User(id: id, name: "Alice")
}
func readFile(at path: String) throws -> Data {
guard FileManager.default.fileExists(atPath: path) else {
throw FileError.notFound(path: path)
}
return try Data(contentsOf: URL(fileURLWithPath: path))
}
func parse(input: String) throws(ParseError) -> Int {
guard let value = Int(input) else {
throw .invalidFormat
}
return value
}
enum ParseError: Error {
case invalidFormat
case outOfRange
}
do {
let data = try readFile(at: "/path/to/file.json")
let user = try JSONDecoder().decode(User.self, from: data)
print("Loaded: \(user.name)")
} catch FileError.notFound(let path) {
print("File not found: \(path)")
} catch FileError.permissionDenied(let path) {
print("Permission denied: \(path)")
} catch {
print("Unexpected error: \(error)")
}
// try? -- returns nil on error (discards error information)
let data = try? readFile(at: "/path/to/file.json")
if let data {
print("Read \(data.count) bytes")
}
// try! -- crashes on error (use only when you are certain it succeeds)
let content = try! readFile(at: "/known/good/file.txt")
func processFile(path: String) {
do {
let data = try readFile(at: path)
let decoded = try JSONDecoder().decode(Config.self, from: data)
apply(config: decoded)
} catch FileError.notFound(let path) {
print("Missing file: \(path)")
} catch FileError.permissionDenied {
print("Fix permissions")
} catch DecodingError.keyNotFound(let key, _) {
print("Missing key in config: \(key.stringValue)")
} catch DecodingError.typeMismatch(let type, _) {
print("Type mismatch: expected \(type)")
} catch is DecodingError {
print("General decoding error")
} catch {
print("Unknown error: \(error)")
}
}
func loadConfig() throws -> Config {
do {
let data = try readFile(at: "/config.json")
return try JSONDecoder().decode(Config.self, from: data)
} catch let error as DecodingError {
print("Decoding failed: \(error)")
throw AppError(code: 1001, message: "Config decode failed", underlyingError: error)
}
}

The Result<Success, Failure> enum encapsulates a success value or an error, making error handling explicit and composable.

func divide(_ a: Double, _ b: Double) -> Result<Double, ArithmeticError> {
guard b != 0 else {
return .failure(ArithmeticError.divisionByZero)
}
return .success(a / b)
}
let result = divide(10, by: 3)
switch result {
case .success(let value):
print("Result: \(value)")
case .failure(let error):
print("Error: \(error)")
}
let operations: [Result<Double, ArithmeticError>] = [
divide(10, by: 2), // .success(5)
divide(10, by: 0), // .failure
divide(10, by: 4) // .success(2.5)
]
let successes = operations.compactMap { try? $0.get() }
// [5.0, 2.5]
let failures = operations.filter {
if case .failure = $0 { return true }
return false
}
// From throwing to Result
func safeFetchUser(id: Int) -> Result<User, Error> {
Result {
try fetchUser(id: id)
}
}
// From Result to throwing
func unwrap(_ result: Result<Data, Error>) throws -> Data {
switch result {
case .success(let data): return data
case .failure(let error): throw error
}
}
// Using Result.get()
do {
let data = try divide(10, by: 3).get()
print(data)
} catch {
print("Error: \(error)")
}
extension Result {
func map<NewSuccess>(_ transform: (Success) -> NewSuccess) -> Result<NewSuccess, Failure> {
switch self {
case .success(let value): return .success(transform(value))
case .failure(let error): return .failure(error)
}
}
func flatMap<NewSuccess>(_ transform: (Success) -> Result<NewSuccess, Failure>) -> Result<NewSuccess, Failure> {
switch self {
case .success(let value): return transform(value)
case .failure(let error): return .failure(error)
}
}
}
let pipeline = divide(10, by: 2)
.map { $0 * 3 } // .success(15)
.flatMap { divide($0, by: 5) } // .success(3)

A rethrows function passes through errors from its closure argument.

func withRetry<T>(maxAttempts: Int, operation: () throws -> T) rethrows -> T {
var lastError: Error?
for attempt in 1...maxAttempts {
do {
return try operation()
} catch {
lastError = error
print("Attempt \(attempt) failed: \(error)")
}
}
throw lastError! // Guaranteed to exist
}
// Caller must handle errors from the operation
let result = try withRetry(maxAttempts: 3) {
try networkRequest()
}
struct Config: Decodable {
let apiUrl: URL
let timeout: TimeInterval
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let urlString = try container.decode(String.self, forKey: .apiUrl)
guard let url = URL(string: urlString) else {
throw DecodingError.dataCorruptedError(
forKey: .apiUrl, in: container,
debugDescription: "Invalid URL string: \(urlString)"
)
}
self.apiUrl = url
self.timeout = try container.decode(TimeInterval.self, forKey: .timeout)
}
enum CodingKeys: String, CodingKey {
case apiUrl = "api_url"
case timeout
}
}

The defer statement executes cleanup code when the current scope exits, regardless of how it exits (return, throw, break, or normal completion).

func processFile(path: String) throws -> String {
let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path))
defer {
try? fileHandle.close()
}
let data = fileHandle.readDataToEndOfFile()
return String(data: data, encoding: .utf8) ?? ""
}
func transaction() throws {
beginTransaction()
defer {
if !committed {
rollbackTransaction()
}
}
var committed = false
defer {
if committed {
print("Transaction committed successfully")
}
}
try executeStatement("INSERT INTO users ...")
try executeStatement("UPDATE accounts ...")
committed = true
}
func multiDefer() {
print("Start")
defer { print("Defer 1") }
defer { print("Defer 2") }
defer { print("Defer 3") }
print("End")
}
// Output: Start, End, Defer 3, Defer 2, Defer 1
// defers execute in reverse order (LIFO)
func connectDatabase(host: String) throws -> Connection {
guard !host.isEmpty else {
throw ValidationError.emptyField("host")
}
let conn = try Connection(host: host)
defer { conn.close() }
try conn.authenticate()
let data = try conn.query("SELECT * FROM users")
return try conn.process(data)
}
enum DatabaseError: Error, LocalizedError {
case connectionFailed(host: String)
case queryFailed(query: String, reason: String)
case timeout(seconds: Double)
var errorDescription: String? {
switch self {
case .connectionFailed(let host):
return "Cannot connect to database at \(host)"
case .queryFailed(let query, let reason):
return "Query "\(query)' failed: \(reason)"
case .timeout(let seconds):
return "Database operation timed out after \(seconds)s"
}
}
var recoverySuggestion: String? {
switch self {
case .connectionFailed:
return "Check network connection and database status"
case .queryFailed:
return "Verify query syntax and table schema"
case .timeout:
return "Consider increasing timeout or optimising the query"
}
}
}
enum APIResponse<T> {
case success(T)
case failure(APIError)
var value: T? {
switch self {
case .success(let value): return value
case .failure: return nil
}
}
var error: APIError? {
switch self {
case .success: return nil
case .failure(let error): return error
}
}
}
enum APIError: Error {
case unauthorized
case notFound
case serverError(Int)
case networkError(Error)
}
func validateEmail(_ email: String) -> Result<String, ValidationError> {
guard !email.isEmpty else {
return .failure(.emptyField("email"))
}
guard email.contains("@") && email.contains(".") else {
return .failure(.invalidEmail(email))
}
return .success(email)
}
// Usage
switch validateEmail("test@example.com") {
case .success(let email):
print("Valid: \(email)")
case .failure(let error):
print("Invalid: \(error)")
}
enum LoadError: Error, LocalizedError {
case networkUnavailable
case serverUnreachable
var errorDescription: String? {
switch self {
case .networkUnavailable: return "No internet connection"
case .serverUnreachable: return "Server is unreachable"
}
}
}
@MainActor
class ViewModel: ObservableObject {
@Published var items: [Item] = []
@Published var errorMessage: String?
@Published var isLoading = false
func load() async {
isLoading = true
errorMessage = nil
do {
items = try await fetchItems()
} catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
isLoading = false
}
}
struct ContentView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
Group {
if let errorMessage = viewModel.errorMessage {
Text("Error: \(errorMessage)").foregroundStyle(.red)
} else if viewModel.isLoading {
ProgressView()
} else {
List(viewModel.items) { item in
Text(item.title)
}
}
}
.task { await viewModel.load() }
}
}
func processData(input: String) throws -> ProcessedResult {
guard !input.isEmpty else {
throw ValidationError.emptyField("input")
}
guard input.count >= 3 else {
throw ValidationError.passwordTooShort(minLength: 3)
}
let cleaned = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleaned.isEmpty else {
throw ValidationError.emptyField("cleaned input")
}
let parsed = try parse(cleaned)
let validated = try validate(parsed)
return try transform(validated)
}
struct User: Codable {
let id: Int
let name: String
let email: String
}
func decodeUser(from data: Data) -> Result<User, Error> {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
do {
let user = try decoder.decode(User.self, from: data)
return .success(user)
} catch let DecodingError.keyNotFound(key, context) {
return .failure(AppError(
code: 1001,
message: "Missing key: \(key.stringValue)",
underlyingError: error
))
} catch let DecodingError.typeMismatch(type, context) {
return .failure(AppError(
code: 1002,
message: "Type mismatch for \(type)",
underlyingError: error
))
} catch let DecodingError.valueNotFound(type, context) {
return .failure(AppError(
code: 1003,
message: "Nil value for non-optional type: \(type)",
underlyingError: error
))
} catch {
return .failure(error)
}
}

Error handling in Swift is like a safety protocol in a laboratory. The try/catch/finally pattern ensures that when something goes wrong, you clean up properly and inform someone about the problem. The do/try/catch syntax makes the error path explicit: you must acknowledge that an operation might fail.

Optional chaining is like a series of connected pipes. If any pipe in the chain is broken (nil), the water (value) does not flow through. You do not need to check each pipe individually; the chain handles it for you. This is elegant but means you must decide what to do when the chain breaks.

Example 1: Nested Result Pipeline with Error Mapping

Section titled “Example 1: Nested Result Pipeline with Error Mapping”

Problem: Chain multiple operations that return Result, mapping domain-specific errors to a unified error type at each step.

enum ApiError: Error {
case networkFailure(underlying: Error)
case decodingFailure(underlying: Error)
case validationFailure(field: String, message: String)
}
func fetchRaw(url: URL) -> Result<Data, ApiError> {
// Simulates network call
.failure(.networkFailure(underlying: URLError(.timedOut)))
}
func decode<T: Decodable>(_ type: T.Type, from data: Data) -> Result<T, ApiError> {
do {
return .success(try JSONDecoder().decode(type, from: data))
} catch {
return .failure(.decodingFailure(underlying: error))
}
}
func validate<T>(_ value: T, _ predicate: (T) -> Bool, field: String, message: String) -> Result<T, ApiError> {
predicate(value) ? .success(value) : .failure(.validationFailure(field: field, message: message))
}
// Compose the pipeline
let result = fetchRaw(url: URL(string: "https://api.example.com/user")!)
.flatMap { decode(User.self, from: $0) }
.flatMap { validate($0, { !$0.name.isEmpty }, field: "name", message: "Name required") }
switch result {
case .success(let user): print("Valid user: \(user.name)")
case .failure(let error):
switch error {
case .networkFailure: print("Check your connection")
case .decodingFailure: print("Server returned invalid data")
case .validationFailure(let field, let msg): print("\(field): \(msg)")
}
}

Explanation: Each step returns a Result. flatMap chains steps, short-circuiting on the first failure. Error mapping occurs at the boundary between domain layers: network errors become .networkFailure, JSON errors become .decodingFailure. The final switch handles all error cases exhaustively.


Example 2: Resource Cleanup with Nested defer

Section titled “Example 2: Resource Cleanup with Nested defer”

Problem: Read a configuration file, parse it, and apply settings. Ensure the file handle closes, any temporary resources are cleaned up, and a lock is released — regardless of which step fails.

func applyConfiguration(from path: String) throws -> Config {
let lock = NSLock()
lock.lock()
defer {
lock.unlock()
print("Lock released")
}
let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path))
defer {
fileHandle.closeFile()
print("File closed")
}
let data = fileHandle.readDataToEndOfFile()
let config = try JSONDecoder().decode(Config.self, from: data)
guard config.isValid else {
throw ValidationError.emptyField("configuration")
}
print("Configuration applied: \(config)")
return config
}

Explanation: defer blocks execute in reverse order (LIFO) when the scope exits. If decode or isValid throws, both the file handle and lock are still cleaned up. The reverse order ensures the lock is released last, maintaining the invariant that resources are freed in the correct sequence.


Example 3: Async Error Recovery with Retry and Fallback

Section titled “Example 3: Async Error Recovery with Retry and Fallback”

Problem: Implement a fetch function that retries on transient failures, falls back to a cache on permanent failure, and converts errors to a user-friendly Result.

enum DataError: LocalizedError {
case transient(Error)
case permanent(Error)
case fallbackUsed(Error)
var errorDescription: String? {
switch self {
case .transient: return "Temporary failure, retrying..."
case .permanent(let e): return "Unrecoverable: \(e.localizedDescription)"
case .fallbackUsed(let e): return "Using cached data (fresh unavailable: \(e.localizedDescription))"
}
}
}
func fetchWithFallback(id: Int) async -> Result<User, DataError> {
var lastError: Error?
for attempt in 1...3 {
do {
let user = try await fetchUser(id: id)
return .success(user)
} catch let error as URLError where [.timedOut, .networkConnectionLost].contains(error.code) {
lastError = error
try? await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt)) * 500_000_000))
continue
} catch {
return .failure(.permanent(error))
}
}
if let cached = try? await loadCachedUser(id: id) {
return .failure(.fallbackUsed(lastError!))
}
return .failure(.transient(lastError!))
}
// Usage
let result = await fetchWithFallback(id: 42)
switch result {
case .success(let user): updateUserUI(user)
case .failure(let error): showError(error.localizedDescription)
}

Explanation: The retry loop handles transient network errors with exponential backoff. Non-transient errors fail immediately via .permanent. After exhausting retries, the function attempts a cache fallback, returning .fallbackUsed to indicate degraded data. The Result type makes all three outcomes explicit and composable.

flowchart TD
    A[1_Error Handling] --> B[Key Concepts]
    A --> C[Core Principles]
    A --> D[Practical Applications]
    B --> E[Fundamental definitions]
    C --> F[Design patterns]
    D --> G[Real-world usage]

Swift’s error handling is explicit and type-safe. Enum-based error types provide structured error information. The do/catch/try pattern propagates errors while Result makes error handling composable. defer ensures cleanup code runs reliably, and guard keeps the happy path clean. Combined, these tools enable robust error handling without the overhead of exceptions in other languages.

Using try! without being certain the call will not throw. try! force-unwraps the result and crashes if an error is thrown. Only use try! when you have verified that the function cannot fail in the current context. In most cases, try with do/catch or try? is safer.

Catching too broad an exception type. Writing catch { } without specifying an error type catches everything, including programming errors that should crash. Always catch specific error types: catch NetworkError.timeout { }. This prevents silently swallowing unexpected errors.

Forgetting that defer runs even when an error is thrown. The defer block executes when leaving the current scope, regardless of whether an error occurred. This is useful for cleanup (closing files, releasing locks) but can cause unexpected behaviour if the deferred code has side effects. Place defer at the beginning of the scope for clarity.

  • Variables and Types - How optionals relate to error handling with try? and optional chaining
  • Functions - How throwing functions and rethrows extend the function type system
  • Concurrency - How async/await integrates with try/catch for concurrent error handling