Using Swift’s Result Type for Clear, Composable Error Handling
Learn how Swift’s built‑in Result type transforms error handling into a clear, composable value‑based pattern. See a concrete example, trade‑offs, and actionable steps for modern codebases.
14 Apr 2026, 09:36 UTC

When Do You Need a Cleaner Error Path?
Imagine a function that fetches user data from a network endpoint. In the traditional throw‑based model you write:
func loadUser(id: String) throws -> User {
let data = try URLSession.shared.data(from: URL(string: "https://api.example.com/users/\(id)")!)
return try JSONDecoder().decode(User.self, from: data)
}
Every call site must wrap the invocation in a do‑catch block or propagate the error further. The control flow jumps to the catch block, making it hard to reason about the *happy* path. If you need to chain several such calls, you end up with deeply nested try statements or a cascade of do‑catch blocks.
Result: A Value‑Based Alternative
The Result enum, introduced in Swift 5, captures the outcome of an operation as a value:
enum Result<Success, Failure: Error> {
case success(Success)
case failure(Failure)
}
Instead of throwing, a function can return Result<User, Error>. The caller then pattern‑matches on .success or .failure, keeping the happy path linear and the error path explicit.
Benefits at a Glance
- Explicitness: The type signature tells you whether an error is possible.
- Composability: Functional helpers like
mapandflatMaplet you chain operations without nestedtrys. - Testability: Unit tests can assert on the returned
Resultdirectly, without needing to catch exceptions. - Async/Await Friendly: Async functions can return
Resultinstead of throwing, simplifying UI logic that needs to display both success and failure states.
Concrete Example: A Small Fetch Layer
Below is a minimal playground‑style example that demonstrates how to use Result with both synchronous decoding and an async wrapper.
// MARK: - Domain model
struct User: Decodable {
let id: String
let name: String
}
// MARK: - Synchronous fetch returning Result
func fetchUserSync(id: String) -> Result {
guard let url = URL(string: "https://api.example.com/users/\(id)") else {
return .failure(NSError(domain: "InvalidURL", code: -1, userInfo: nil))
}
do {
let data = try Data(contentsOf: url)
let user = try JSONDecoder().decode(User.self, from: data)
return .success(user)
} catch {
return .failure(error)
}
}
// MARK: - Async wrapper that returns Result
func fetchUserAsync(id: String) async -> Result {
guard let url = URL(string: "https://api.example.com/users/\(id)") else {
return .failure(NSError(domain: "InvalidURL", code: -1, userInfo: nil))
}
do {
let (data, _) = try await URLSession.shared.data(from: url)
let user = try JSONDecoder().decode(User.self, from: data)
return .success(user)
} catch {
return .failure(error)
}
}
// MARK: - Using map and flatMap
let result = fetchUserSync(id: "123")
let transformed = result.map { user in
"Hello, \(user.name)!"
}
// MARK: - Pattern matching in UI code
switch transformed {
case .success(let greeting):
print(greeting) // e.g., "Hello, Alice!"
case .failure(let error):
print("Failed to greet user: \(error.localizedDescription)")
}
Key points in the snippet:
- The function returns a
Resultso callers can decide how to handle the error. - Using
maplets us transform theUserinto a greeting string without unwrapping the result. - The async version mirrors the sync one but uses
awaitinside ado‑catchblock, still returning aResult.
When to Keep Throwing
Result is not a silver bullet. If an API is part of a public library or must interoperate with Objective‑C, the conventional throws pattern remains the most compatible. Also, for functions where the error path is the primary concern—like low‑level system calls—throwing can be more expressive, especially when you rely on Swift’s built‑in stack traces.
Nested Results: A Caution
Chaining many Result operations can lead to nested types such as Result<Result<User, Error>, Error>. Use type aliases or helper functions to flatten:
typealias UserResult = Result<User, Error>
func safeFetch(id: String) -> UserResult {
return fetchUserSync(id: id)
}
Flattening keeps the code readable and avoids cumbersome pattern matching.
Actionable Takeaways
- Adopt
Resultfor new APIs where the error state is part of the contract, especially in UI‑centric layers. - Use functional helpers (
map,flatMap,switchMapin Combine) to keep pipelines clean. - When porting existing throw‑based code, consider wrapping the throwing call in a
Resulthelper to provide a quick migration path. - Always provide a type alias for complex
Resultsignatures to improve readability. - In tests, assert on the
Resultdirectly:XCTAssertEqual(result, .success(expectedUser)).
By treating errors as values rather than control flow, you make your Swift code more predictable, testable, and easier to compose—especially in the era of async/await and reactive frameworks.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.