Error Handling in Swift: `do`, `try`, `catch`, and `throws`
In Finding Nemo, Marlin’s plan to keep Nemo safe goes sideways almost immediately — a diver snatches Nemo, a shark chase goes wrong, and jellyfish sting everyone. But each time, Marlin adapts and recovers. Your Swift code faces the same reality: network calls fail, files go missing, and users do unexpected things. Error handling is how your code recovers gracefully instead of crashing.
In this guide, you’ll learn how to define errors, throw and catch them, and choose between try?, try!, and the
Result type. We won’t cover async error handling with async/await — that has its own dedicated post.
What You’ll Learn
- What Is Error Handling?
- Defining Errors
- Throwing Functions
do/try/catchtry?andtry!- The
ResultType - Common Mistakes
- What’s Next?
What Is Error Handling?
Error handling is a structured way to signal that something went wrong, pass that failure up to the code that can deal with it, and recover — all without crashing the app.
Think of it like the ocean in Finding Nemo. Marlin doesn’t just swim blindly hoping nothing goes wrong. When he hits a jellyfish forest, he recognizes the danger (the error), signals it to Dory (the throw), and they change course (the catch). Swift’s error handling follows the same pattern: detect, signal, recover.
Apple Docs:
Error— Swift Standard Library
Defining Errors
In Swift, errors are values that conform to the Error protocol. The most common way to define errors is with an
enum — each case represents a specific failure:
enum OceanError: Error {
case tooDeep
case sharkAttack
case jellyfishSting
case currentTooStrong
}
Each case describes what went wrong. This is much more useful than a generic “something failed” message — the caller can inspect the specific error and decide how to recover.
You can also attach associated values to carry extra information:
enum DiveError: Error {
case depthExceeded(maxDepth: Int)
case oxygenLow(remaining: Int)
}
Tip: Name your error types with a descriptive suffix like
Error—NetworkError,FileError,OceanError. This makes them easy to find and understand at a glance.
Throwing Functions
A function that can fail is marked with the throws keyword. When something goes wrong inside, you use throw to send
the error to the caller:
func dive(to depth: Int) throws -> String {
if depth > 100 {
throw OceanError.tooDeep
}
return "Reached \(depth) meters safely!"
}
The throws keyword goes between the parameters and the return type. It tells callers: “This function might fail — you
need to handle that possibility.”
func crossJellyfishForest(hasProtection: Bool) throws {
if !hasProtection {
throw OceanError.jellyfishSting
}
print("Crossed the jellyfish forest safely!")
}
Note: A function marked
throwsdoesn’t have to throw every time — it just reserves the right to. When everything goes well, it returns normally.
do / try / catch
To call a throwing function, you wrap the call in a do block, prefix it with try, and handle failures in catch
blocks:
do {
let result = try dive(to: 50)
print(result)
} catch OceanError.tooDeep {
print("That's too deep! Turn back.")
} catch {
print("Something went wrong: \(error)")
}
Reached 50 meters safely!
When the depth exceeds the limit, the error triggers the matching catch block:
do {
let result = try dive(to: 200)
print(result)
} catch OceanError.tooDeep {
print("That's too deep! Turn back.")
} catch {
print("Something went wrong: \(error)")
}
That's too deep! Turn back.
You can catch specific error cases to handle each failure differently. The final catch without a pattern is the
catch-all — it receives a variable named error automatically:
do {
try crossJellyfishForest(hasProtection: false)
} catch OceanError.jellyfishSting {
print("Ouch! Marlin got stung!")
} catch OceanError.sharkAttack {
print("Swim away! Shark!")
} catch {
print("Unexpected error: \(error)")
}
Ouch! Marlin got stung!
Tip: Always include a catch-all at the end of your
catchblocks. Swift requires thatdo/catchhandles all possible errors, and the catch-all satisfies this requirement.
try? and try!
Swift offers two shortcuts for calling throwing functions when you don’t need the full do/catch ceremony.
try? — Convert to Optional
try? calls the function and converts the result to an optional. If the function succeeds, you get the value wrapped in
an optional. If it throws, you get nil:
let shallow = try? dive(to: 30)
print(shallow as Any)
let tooFar = try? dive(to: 500)
print(tooFar as Any)
Optional("Reached 30 meters safely!")
nil
This is handy when you only care about the success case and can quietly handle failure with a default:
let depth = try? dive(to: 300)
let message = depth ?? "Dive failed — staying on the surface"
print(message)
Dive failed — staying on the surface
try! — Force Try (Dangerous!)
try! assumes the function will never fail. If it does, your app crashes:
// This works because 10 < 100
let safeDive = try! dive(to: 10)
print(safeDive)
Reached 10 meters safely!
// ⛔ This would crash at runtime!
// let crashDive = try! dive(to: 999)
// Fatal error: 'try!' expression unexpectedly raised an error
Warning: Avoid
try!in production code. It’s the error-handling equivalent of force unwrapping — it works until it doesn’t, and then your app crashes. Use it only in tests or when failure is truly impossible.
The Result Type
Swift’s Result type provides an alternative to do/catch. A Result is an enum with two cases: .success holding
the value, and .failure holding the error:
func findNemo(in ocean: String) -> Result<String, OceanError> {
if ocean == "Pacific" {
return .success("Found Nemo in the Pacific!")
} else {
return .failure(.tooDeep)
}
}
You handle the result with a switch:
let search = findNemo(in: "Pacific")
switch search {
case .success(let message):
print(message)
case .failure(let error):
print("Search failed: \(error)")
}
Found Nemo in the Pacific!
And when the search fails:
let badSearch = findNemo(in: "Arctic")
switch badSearch {
case .success(let message):
print(message)
case .failure(let error):
print("Search failed: \(error)")
}
Search failed: tooDeep
Result is especially useful when passing results through callbacks or storing outcomes for later processing, where
do/catch would be awkward.
Note: You can convert between
Resultand throwing functions. Calltry result.get()to extract the success value or throw the failure.
Common Mistakes
Using try! in Production Code
// ❌ Crashes if the file doesn't exist
// let data = try! Data(contentsOf: fileURL)
// ✅ Handle the error gracefully
do {
let data = try Data(contentsOf: fileURL)
print("Loaded \(data.count) bytes")
} catch {
print("Could not load file: \(error)")
}
The try! version works in development when the file exists, but crashes in production when it doesn’t. Always use
do/catch or try? for operations that can realistically fail.
Silently Ignoring Errors
// ❌ Swallows the error — you'll never know what went wrong
// _ = try? riskyOperation()
// ✅ At minimum, log the failure
if let result = try? riskyOperation() {
print("Success: \(result)")
} else {
print("Operation failed — check logs")
}
Using _ = try? throws away both the error and the result. If something goes wrong, you have no information to debug
with. At the very least, check the optional and log the failure path.
What’s Next?
- Error handling lets your code signal, propagate, and recover from failures.
- Define errors as
enumtypes conforming to theErrorprotocol. - Use
throwsto mark functions that can fail, andthrowto signal errors. - Wrap throwing calls in
do { try ... } catch { ... }to handle specific errors. try?converts errors tonil— useful for simple fallbacks.try!crashes on failure — avoid it in production code.Result<Value, Error>is an alternative whendo/catchis impractical.
Your code is getting more structured, but who can see and use your types, properties, and methods? Head to Access Control in Swift to learn how Swift’s five access levels help you design clean, safe APIs.