Overview
Swift’s error model is explicit: a function that can fail must declare it with throws, and callers must acknowledge failures with try. This page covers when to use throws versus optionals or Result, how to group errors by domain, typed throws in Swift 6, and the boundary rule. Read swift for the overview, and swift-optionals for the distinction between absence and failure.
Prefer throws over optionals to signal failure
An optional return communicates absence; a thrown error communicates failure with a reason. Use throws when the caller needs to know why an operation failed.
// Bad: nil tells the caller nothing
func parseDate(_ string: String) -> Date? { ... }
// Better: the caller knows what went wrong
enum DateParseError: Error {
case emptyInput
case invalidFormat(String)
case outOfRange(Date)
}
func parseDate(_ string: String) throws -> Date { ... }Use try? only when failure genuinely means “skip and continue” and the reason is irrelevant to the caller. Use try! only in tests or in application-startup code where failure is a programmer error that should crash loudly.
Group errors by domain, not by operation
One error type per subsystem keeps switch statements exhaustive and short. An error type with one case per function call creates massive switch exhaustion across callers.
// Too granular: one type per operation
enum FetchProfileError: Error { case networkFailure, decodingFailure }
enum SaveProfileError: Error { case diskFull, permissionDenied }
// Better: one type for the whole persistence domain
enum PersistenceError: Error {
case networkFailure(URLError)
case decodingFailure(DecodingError)
case diskFull
case permissionDenied
}Name the type after the domain it belongs to. Internal code lets errors propagate; the entry point for each subsystem catches and translates into the next domain’s error type. See swift-async-await for how this interacts with async boundaries.
Use typed throws in Swift 6 for well-bounded error domains
Swift 6 allows throws(ErrorType) to specify the exact error type a function throws. The compiler enforces exhaustive handling without an as? cast.
enum CacheError: Error {
case miss(key: String)
case expired(key: String, at: Date)
case corrupted(key: String)
}
func load(key: String) throws(CacheError) -> Data { ... }
// Caller's do-catch is exhaustive without needing a default clause
do {
let data = try load(key: "profile")
process(data)
} catch .miss(let key) {
await fetchRemote(key: key)
} catch .expired(let key, _) {
await refresh(key: key)
} catch .corrupted(let key) {
await invalidate(key: key)
}Use untyped throws (erased to any Error) when the function delegates to several subsystems that each have their own error types. Use typed throws when the caller must handle every case and the error set is closed.
Use Result at asynchronous boundaries
Result<Value, Failure> is useful when you need to store a completed or failed operation and resolve it later, or when bridging a callback API that cannot be converted with withCheckedContinuation.
typealias FetchResult = Result<Data, URLError>
func cache(result: FetchResult, for key: String) {
responseCache[key] = result
}
func resolve(key: String) throws -> Data {
guard let result = responseCache[key] else { throw CacheError.miss(key: key) }
return try result.get()
}Inside fully async code, async throws is cleaner than returning Result. Reserve Result for the case where the outcome must be a value rather than a thrown error.
Mark rethrowing functions with rethrows
A function that accepts a throwing closure and propagates its errors should be rethrows, not throws. This preserves the call site’s non-throwing status when a non-throwing closure is passed.
func measure<T>(_ label: String, block: () throws -> T) rethrows -> T {
let start = Date()
defer { log(label, duration: Date().timeIntervalSince(start)) }
return try block()
}
// Non-throwing call: no try needed at the call site
let result = measure("sort") { array.sorted() }
// Throwing call: compiler requires try
let decoded = try measure("decode") { try JSONDecoder().decode(Item.self, from: data) }rethrows makes the function’s throwing behavior conditional on its closure argument. Omitting it forces every caller to write try even when they pass a non-throwing closure.
Handle failures at the boundary; propagate inside
Internal functions should let errors propagate with throws. The function that represents a user action or network event is the right place to catch, log, and translate.
// Internal: propagates freely
func fetchUser(id: UUID) async throws -> User {
let data = try await session.data(from: endpoint(for: id)).0
return try JSONDecoder().decode(User.self, from: data)
}
// Boundary: catches and maps to UI state
@MainActor
func loadProfile(id: UUID) async {
do {
profile = try await fetchUser(id: id)
} catch is URLError {
errorMessage = "Check your connection."
} catch {
errorMessage = "Something went wrong."
logger.error("\(error)")
}
}This keeps the internal code clean and keeps error-handling policy in one place per subsystem. See swift-actors for the @MainActor boundary pattern.