When things go wrong, a robust error-handling strategy keeps your app from crashing into your users' hands. Swift error handling: a Swift & iOS guide for developers will walk you through the language's built-in mechanisms—throws, try, and do-catch blocks—so you can gracefully recover from failures and provide better feedback to users.
In production systems handling e-commerce transactions, user authentication, or real-time data sync, errors are inevitable. Network timeouts, missing resources, invalid input, and permission denials happen every day. Swift gives us the tools to anticipate these problems and handle them with precision, rather than letting exceptions bubble up and crash the app. We'll explore how to define errors, throw them from functions, catch them safely, and use Swift 6's typed throws to make error handling both powerful and expressive.
Define errors — Enums conforming to Error
The foundation of Swift error handling is defining what can go wrong in your code. Unlike some languages that throw generic exceptions, Swift encourages you to be specific about the errors your functions can produce. The simplest way is to create an enum that conforms to the Error protocol.
Let's say we're building a user profile feature. We need to handle cases where the profile isn't found, the network is unreachable, or the data is corrupted:
enum ProfileError: Error {
case notFound
case networkUnavailable
case invalidData
case unauthorized
case serverError(statusCode: Int)
}Each case represents a distinct failure mode. When a case needs additional context—like a server error with a specific HTTP status code—we can associate values. This lets callers know exactly what happened and respond appropriately.
In a real e-commerce app, you might have separate error enums for different domains: PaymentError, InventoryError, ShippingError. This separation makes error handling clearer and prevents a single monster enum from becoming unmaintainable. The pattern is simple but powerful: define an enum for each distinct responsibility in your code.
Throw and catch — Mark functions with throws
Once you've defined your error types, you mark functions that can fail with the throws keyword. This tells callers that the function might not succeed and that they need to handle the error.
func loadProfile(userId: String) throws -> Profile {
guard !userId.isEmpty else {
throw ProfileError.invalidData
}
let url = URL(string: "https://api.example.com/users/\(userId)")!
let (data, response) = try URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw ProfileError.networkUnavailable
}
guard httpResponse.statusCode == 200 else {
throw ProfileError.serverError(statusCode: httpResponse.statusCode)
}
let decoder = JSONDecoder()
return try decoder.decode(Profile.self, from: data)
}Notice that loadProfile is marked throws, and within the function we use throw to signal failure. When we call another throwing function like URLSession.shared.data(from:) or JSONDecoder.decode(_:from:), we prefix the call with try to acknowledge that it might fail.
To actually handle these errors, we use a do-catch block:
do {
let profile = try loadProfile(userId: "user123")
displayProfile(profile)
} catch ProfileError.notFound {
showEmptyState()
} catch ProfileError.unauthorized {
promptLogin()
} catch ProfileError.serverError(let statusCode) {
showAlert("Server error: \(statusCode)")
} catch {
showAlert("An unexpected error occurred")
}The do block contains code that might throw. If an error is thrown, execution jumps to the first matching catch clause. We can be as specific as we want: catch a single error case, pattern-match on associated values, or use a catch-all at the end. This approach forces us to think about what can go wrong and decide how to respond.
In a CRM system pulling customer data, you might catch network errors separately from authentication errors, retrying the former but redirecting the latter to login. The specificity is what makes error handling effective.
try? and try! — Convert to optional or crash
Sometimes a do-catch block is overkill. Swift provides two shortcuts: try? and try!.
try? converts a throwing expression into an optional. If the function throws, the result is nil. If it succeeds, the result is wrapped in an optional:
let profile = try? loadProfile(userId: "user123")
if let profile = profile {
displayProfile(profile)
} else {
showEmptyState()
}This is clean when you don't need to distinguish between different errors. However, you lose all error context—you can't tell if the network failed, the user wasn't found, or something else went wrong. Use try? when the error details don't matter for your response.
try! says "I'm certain this won't throw; crash if it does." Use it only in cases where you're 100% sure the operation can't fail, or where crashing is acceptable (which is rare in production code):
let defaultProfile = try! loadDefaultProfile() // Only safe if this never fails in any scenarioIn most real-world scenarios, we avoid try!. It's a liability. A seemingly safe assumption today becomes a crash tomorrow when requirements change or a dependency shifts. The compiler doesn't protect us. Teams should establish a strong convention against try! in shipping code, reserving it only for prototypes or guaranteed scenarios (and even then, prefer try? or explicit error handling).
Typed throws — Swift 6 supports declaring specific error types
Swift 6 introduced a powerful refinement: typed throws. Instead of just marking a function throws, you can declare which error type it throws:
func loadProfile(userId: String) throws(ProfileError) -> Profile {
guard !userId.isEmpty else {
throw ProfileError.invalidData
}
let url = URL(string: "https://api.example.com/users/\(userId)")!
let (data, response) = try URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw ProfileError.networkUnavailable
}
guard httpResponse.statusCode == 200 else {
throw ProfileError.serverError(statusCode: httpResponse.statusCode)
}
let decoder = JSONDecoder()
return try decoder.decode(Profile.self, from: data)
}With throws(ProfileError), callers immediately know exactly which error type to expect. This makes catch clauses more precise and reduces the need for catch-all handlers:
do {
let profile = try loadProfile(userId: "user123")
displayProfile(profile)
} catch {
// Swift knows 'error' is ProfileError, so all cases must be handled
switch error {
case .notFound:
showEmptyState()
case .unauthorized:
promptLogin()
case .serverError(let code):
showAlert("Server error: \(code)")
case .networkUnavailable:
showAlert("No connection")
case .invalidData:
showAlert("Invalid input")
}
}You can also declare a function throws a custom error or Never (it doesn't throw at all). Typed throws make error handling more exhaustive—the compiler helps ensure we handle all cases. For new projects targeting iOS 18+ or Swift 6+, typed throws should become your default pattern.
Common mistakes
- Ignoring errors silently: Using
try?everywhere without logging or user feedback. Users see blank screens or missing data with no clue why. Always have a fallback UI or log mechanism. - Over-catching with a single clause: Using one catch-all block for all errors. You lose the ability to respond differently to network vs. data errors. Be specific.
- Throwing generic errors: Defining a single
enum Errorwith dozens of unrelated cases. Instead, define separate enums per responsibility (ProfileError, PaymentError, etc.) and compose them if needed. - Not propagating typed errors upward: Catching and re-throwing a different error type loses context. If a function calls another throwing function, prefer propagating the specific error type rather than wrapping it in a generic outer error.
- Forgetting to use try on throwing calls: Calling a throwing function without
trywon't compile, but beginners sometimes wrap the call in an unnecessarydo-catchwhen they should just propagate the error upward withthrows.
Key takeaways
- Define errors as enums conforming to
Error; use separate enums for distinct domains in your codebase. - Mark functions
throwsand usetryon calls to throwing functions within ado-catchblock to handle specific error cases. - Use
try?when you don't need error details andtry!only in truly infallible scenarios (or better: avoid it entirely in production code). - With Swift 6, use typed throws
throws(SpecificError)to make error handling exhaustive and give callers clear contracts about what can go wrong. - Always provide user-facing feedback for errors; silent failures damage trust and make debugging in production harder.
- Group related error handling logic together; avoid spreading error checks throughout your codebase.






Comments
Loading comments…