Choosing Between Result and Throwing Functions in Swift Error Handling
Guidance on choosing Swift’s Result type versus throwing functions for error handling, with a decision table, trade‑offs, and code examples.
31 May 2026, 07:38 UTC

Decision and constraints
When designing a Swift API you must decide whether errors are propagated as values (Result) or via the language’s throwing mechanism. Use Result when you need to carry errors across non‑throwing boundaries (e.g., Combine publishers, asynchronous sequences, or when storing errors for later inspection). Otherwise prefer throwing functions for idiomatic, concise error handling that works naturally with defer, try?, and async/await.
Constraints:
Resultrequires Swift 5.0 or later.- Throwing functions have been available since Swift 2.0.
- If you combine
Resultwith concurrency features (async/await), target Swift 5.5 or newer.
Comparison table
| Option | Syntax | Propagation | Interoperability | Typical use |
|---|---|---|---|---|
Result<T, Error> |
return .success(value) or .failure(err) |
Explicit via map/flatMap or converting with do‑try‑catch |
Works with Combine, asynchronous sequences, and synchronous code | When errors must be stored, passed through non‑throwing contexts, or inspected later |
| throwing func | func foo() throws -> T | Automatic via try, try?, try!; propagates up the call stack |
Natural with Swift error handling, works with async/await |
Most everyday functions, initializers, and protocol requirements |
Trade‑offs
Result adds boilerplate: every call site must unpack the enum with switch or combinators, and callers must handle both success and failure cases. This makes the error flow visible but can increase code size. The advantage is that errors travel as ordinary values, so they can be sent across threads, stored in collections, or transformed without catching and re‑throwing.
Throwing functions are concise and integrate with defer for resource cleanup. They fit naturally with async/await and the standard do‑try‑catch pattern. However, when an error crosses a concurrency boundary (e.g., from a background thread to the main thread) the implicit propagation can obscure where the error originated, and you cannot store a throwing error directly without wrapping it in a type like Result.
Concrete implementation
Below are two versions of a simple network loader that fetches JSON data and decodes it into a model.
Version returning Result
import Foundation
struct User: Decodable {
let id: Int
let name: String
}
enum LoaderError: Error {
case network(Error)
case decoding(Error)
}
func loadUserResult(completion: @escaping (Result) -> Void) {
let url = URL(string: "https://example.com/user")!
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(.network(error)))
return
}
guard let data = data else {
completion(.failure(.network(URLError(.badServerResponse))))
return
}
do {
let user = try JSONDecoder().decode(User.self, from: data)
completion(.success(user))
} catch {
completion(.failure(.decoding(error)))
}
}
task.resume()
}
// Usage
loadUserResult { result in
switch result {
case .success(let user):
print("Loaded \(user.name)")
case .failure(let err):
print("Error: \(err)")
}
}
Version using throwing functions
import Foundation
struct User: Decodable {
let id: Int
let name: String
}
enum LoaderError: Error {
case network(Error)
case decoding(Error)
}
func loadUserThrowing() async throws -> User {
let url = URL(string: "https://example.com/user")!
let (data, _) = try await URLSession.shared.data(from: url)
do {
return try JSONDecoder().decode(User.self, from: data)
} catch {
throw LoaderError.decoding(error)
}
}
// Usage in an async context
Task {
do {
let user = try await loadUserThrowing()
print("Loaded \(user.name)")
} catch {
print("Error: \(error)")
}
}
Both snippets compile with Swift 5.5 or later. The Result version can be used in Combine pipelines by returning Future or Publisher that wraps the completion handler. The throwing version works directly with async/await and can be called from any asynchronous context.
Validation steps
- Save each snippet to a file (e.g.,
LoaderResult.swiftandLoaderThrowing.swift). - Compile with the Swift compiler:
swiftc -swift-version 5 LoaderResult.swiftandswiftc -swift-version 5 LoaderThrowing.swift. Both should produce executables without warnings. - Write a unit test that calls each function, feeds a known JSON payload, and asserts that the success case matches the expected
Uservalue and that failure cases produce the correctLoaderError. - Run the test suite on macOS and Linux Swift toolchains to confirm cross‑platform behavior.
Limitations: Result does not automatically capture stack traces or line numbers; if debugging context is needed, attach additional information to a custom Error type or prefer throwing functions and use #fileID, #line literals. Mixing the two styles in a single call chain without explicit conversion points can lead to double‑wrapping of errors and confusing traces, so keep a clear boundary where you switch from one representation to the other.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.