A network request either succeeds or fails. But somehow, this simple binary outcome has led to some of the ugliest code I’ve seen — double closures, optional nils pretending to be errors, and callback pyramids full of if-else.
Swift’s standard library Result enum was built exactly for this. This article doesn’t waste time on how to define Result (you don’t need to — it’s built-in). Instead, I’ll show you how to use it in real projects to write clean, type-safe networking layers, and why migrating from double-closure callbacks was one of the best refactors I’ve done.
In my last project, I migrated the networking layer from double-closure callbacks to Result, and the number of “silent failure” bugs dropped from 12 in the first sprint to zero in the next three. The key was that every code path now had to handle both success and failure explicitly — the compiler enforced it. I’ve never gone back to completion handlers since.
What Is Result? (30-Second Recap)
// Result is defined in Swift's standard library — you never need to write this:
// enum Result<Success, Failure: Error> {
// case success(Success)
// case failure(Failure)
// }
// Usage: wrap your network response in one of these two cases
let success: Result<UserProfile, NetworkError> = .success(user)
let failure: Result<UserProfile, NetworkError> = .failure(.noConnection)
That’s it. Success is the type of data you return on success, and Failure is the error type (must conform to Error).
Code 1: Before Result — Three Ugly Patterns I’ve Survived
I’ve inherited all three of these patterns in different codebases. Here’s what they look like and why they fail:
// PATTERN 1: Double closure — the ugliest
func fetchUser(
onSuccess: @escaping (UserProfile) -> Void,
onFailure: @escaping (Error) -> Void
) {
URLSession.shared.dataTask(with: url) { data, _, error in
if let error = error {
onFailure(error)
return
}
guard let data = data else {
onFailure(NSError(domain: "Network", code: -1))
return
}
let user = try? JSONDecoder().decode(UserProfile.self, from: data)
if let user = user {
onSuccess(user)
} else {
onFailure(NSError(domain: "Parse", code: -2))
}
}.resume()
}
// Problem: two closures, no way to guarantee they won't both be called,
// no way to guarantee exactly one is called, and the signature is ugly.
// PATTERN 2: Optional to indicate failure — the most dangerous
func fetchUser() -> UserProfile? {
// ... network call ...
return user // nil means "something went wrong"
}
// Problem: you lose ALL error information. Was it a network error? Parse error?
// Server 404? You just get nil. When debugging at 3am, "request failed" tells
// you nothing. I spent 2 hours once chasing a nil that turned out to be a
// simple JSON key mismatch — impossible to find without error details.
// PATTERN 3: Error pointer — the Objective-C way
func fetchUser(user: inout UserProfile?, error: inout Error?) {
// ... Obj-C style ...
}
// Problem: verbose, requires inout, easy to forget checking error after call.
Each pattern has different problems, but the core issue is: the caller can’t tell from the function signature that “this callback only has two possible states.” Result encodes both states explicitly in the type system, and the compiler guarantees no third state exists.
Code 2: Refactoring the Networking Layer with Result
Here’s my standard approach — the same pattern I’ve used in three production apps:
// The networking layer returns Result instead of double closures
enum NetworkError: Error {
case noConnection
case serverError(Int) // HTTP status code
case decodingError(Error)
case unknown
}
func fetchUser() async -> Result<UserProfile, NetworkError> {
let url = URL(string: "https://api.example.com/me")!
do {
let (data, response) = try await URLSession.shared.data(from: url)
// Check HTTP status
guard let httpResponse = response as? HTTPURLResponse,
200...299 ~= httpResponse.statusCode else {
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
return .failure(.serverError(code))
}
// Decode JSON
do {
let user = try JSONDecoder().decode(UserProfile.self, from: data)
return .success(user)
} catch {
return .failure(.decodingError(error))
}
} catch {
return .failure(.noConnection)
}
}
// Calling it — clean, explicit, compiler-enforced
let result = await fetchUser()
switch result {
case .success(let user):
print("Welcome, \(user.name)")
case .failure(let error):
switch error {
case .noConnection: print("Check your network")
case .serverError(let code): print("Server error \(code)")
case .decodingError: print("Data format changed")
case .unknown: print("Something went wrong")
}
}
// switch MUST handle both success and failure — the compiler enforces it.
// Forgetting a branch fails compilation. This is Result's greatest value:
// forcing you to handle failure.
Code 3: try? / try! and Their Relationship to Result
try? converts a throwing expression into an Optional. Result does the same thing but keeps the error information. Here’s the difference:
// try? — converts to Optional, LOSES the error
func fetchUserOptional() -> UserProfile? {
let (data, _) = try? URLSession.shared.data(from: url)
return try? data.map { try JSONDecoder().decode(UserProfile.self, from: $0) }
}
// If this returns nil — you have NO idea why. Network? Parse? Server error?
// You're debugging blind.
// Result — converts to Result, KEEPS the error
func fetchUserResult() -> Result<UserProfile, NetworkError> {
guard let (data, response) = try? URLSession.shared.data(from: url),
let http = response as? HTTPURLResponse,
200...299 ~= http.statusCode else {
return .failure(.noConnection)
}
do {
return .success(try JSONDecoder().decode(UserProfile.self, from: data))
} catch {
return .failure(.decodingError(error))
}
}
// Now you know EXACTLY what went wrong. In production, this error detail
// is the difference between "fixed in 5 minutes" and "fixed never."
// try! — crashes on failure. Never use for network calls.
// let user = try! fetchUser() // 💥 crash if server returns 500
Code 4: Consuming Result in the ViewModel Layer
When the networking layer returns Result, the ViewModel shouldn’t pass Result directly to the View. Instead, convert it into the ViewModel’s own state:
// ViewModel defines its own UI states — decoupled from network details
enum ViewState<T> {
case idle
case loading
case loaded(T)
case error(String) // user-friendly message, not raw error
}
@MainActor
class ProfileViewModel: ObservableObject {
@Published var state: ViewState<UserProfile> = .idle
func loadProfile() async {
state = .loading
let result = await fetchUser()
// Convert Result to ViewState — this is the "business boundary"
switch result {
case .success(let user):
state = .loaded(user)
case .failure(let error):
// Map technical error to user-friendly message
switch error {
case .noConnection:
state = .error("No internet connection. Please try again.")
case .serverError(let code):
state = .error("Server error (\(code)). Please try later.")
case .decodingError:
state = .error("Data format changed. Please update the app.")
case .unknown:
state = .error("Something went wrong.")
}
}
}
}
// The View only cares about ViewState — no knowledge of Result or NetworkError
struct ProfileView: View {
@StateObject private var vm = ProfileViewModel()
var body: some View {
switch vm.state {
case .idle: Text("Pull to refresh")
case .loading: ProgressView()
case .loaded(let user): Text("Welcome, \(user.name)")
case .error(let msg): VStack {
Text(msg)
Button("Retry") { Task { await vm.loadProfile() } }
}
}
}
}
// Why this wins: the ViewModel decouples network errors from UI display.
// It only cares about "what message to show," not "why this error happened."
// It's also testable — you can mock the service to return specific Result values.
Transforming Result with map and flatMap
Result comes with map and flatMap, allowing you to chain operations just like with arrays — perfect for data transformation:
// map: transform the success value without touching the failure
let nameResult = fetchUser().map { $0.name }
// Result<String, NetworkError> — success gives you just the name string
// flatMap: chain dependent operations
let avatarResult = fetchUser().flatMap { user in
// Only runs if fetchUser() succeeded
fetchAvatar(for: user.avatarURL)
}
// If fetchUser() fails, fetchAvatar is never called.
// flatMap's semantics: "If the first step succeeds, use its result for the
// second; if the first step fails, the whole chain fails."
// Chaining multiple steps — flatMap prevents nesting
let result = fetchUser()
.flatMap { user in fetchProfile(user.id).map { profile in (user, profile) } }
.flatMap { (user, profile) in
fetchAvatar(user.avatarURL).map { avatar in
ProfileData(user: user, profile: profile, avatar: avatar)
}
}
// Each flatMap = one step in the pipeline. If ANY step fails, the rest is skipped.
// Compare this to the callback version: 3 levels of nested closures vs a clean chain.
A Complete Real-World Scenario: Login + Fetch Profile
Let’s wrap up with a full flow from an actual project:
// Step 1: Login
func login(email: String, password: String) async -> Result<AuthToken, NetworkError> {
// ... POST to /login ...
}
// Step 2: Fetch profile (depends on login token)
func fetchProfile(token: String) async -> Result<UserProfile, NetworkError> {
// ... GET /profile with token header ...
}
// Step 3: Chain them with flatMap
func loginAndLoadProfile(email: String, password: String) async -> Result<UserProfile, NetworkError> {
return await login(email: email, password: password)
.flatMap { token in
// Only runs if login succeeded
// .value extracts the success case (non-async wrapper)
Task { await fetchProfile(token: token.value) }
}
}
// Step 4: Consume in ViewModel
@MainActor
class LoginViewModel: ObservableObject {
@Published var state: ViewState<UserProfile> = .idle
func login(email: String, password: String) async {
state = .loading
let result = await loginAndLoadProfile(email: email, password: password)
switch result {
case .success(let profile):
state = .loaded(profile)
case .failure(let error):
state = .error(error.localizedDescription)
}
}
}
// One function per module, Result all the way down, converted to ViewState at the boundary.
// This is my standard template for every new project.
Summary
Three core lessons from experience:
• Never use Optional to represent a network error. nil loses error information. When debugging, you’ll just see “request failed” and be completely lost.
• Convert Result at the business boundary. The network layer returns Result<Data, NetworkError>; the ViewModel converts it to ViewState. Don’t pass Result all the way to the View layer.
• Master map and flatMap. They make request chains flow like a pipe, not nest like Russian dolls.
One last thought: Result isn’t something you need to use — it’s something you can’t go back from once you do. Once you’ve unified your network layer’s return format, you’ll find that writing network requests in any new module takes just three lines of code. The rest is all business logic. That’s the value of good design.
