If you're coming to Swift from Java, C#, or Python (see how Kotlin handles coroutine exceptions for comparison), the first thing to unlearn is this: Swift does not have exceptions. When developers talk about "swift exceptions," they're often misunderstanding the language's error model. The throw keyword is not an exception—it's a typed, checked error return with special syntax. There's no automatic stack unwinding across arbitrary call frames, no silent nil-unwrap exceptions, and no untyped catch-alls. Understanding do/try/catch means grasping that Swift's error handling is explicit, exhaustive, and compiler-enforced at every call site. This matters because getting it right prevents whole classes of bugs that exceptions hide.
Developers who skip learning this well end up either swallowing errors silently or force-unwrapping to satisfy the compiler, both of which hide bugs in production. The right path is learning how Swift's error protocol, typed throws, and catch patterns actually work—then knowing which tool (try?, try!, defer, Result, async/await) fits each situation. This is different from the fatalError vs exceptions question: here we're diving into the practical mechanics of do/try/catch itself.
The Error Protocol and Custom Error Enums
Every thrown error in Swift must conform to the Error protocol. The simplest pattern is a custom enum with associated values:
enum NetworkError: Error {
case timeout
case unreachable
case invalidResponse(statusCode: Int)
case decodingFailed(reason: String)
}
Associated values let you pass context about what went wrong. When you throw, the error type is known and available to callers:
func fetchUser(id: Int) throws(NetworkError) -> User {
let response = try makeRequest(to: "/users/\(id)")
guard response.statusCode == 200 else {
throw NetworkError.invalidResponse(statusCode: response.statusCode)
}
return try parseJSON(response.data)
}
For user-facing messages, conform to LocalizedError:
enum ParseError: LocalizedError {
case malformedJSON
case unsupportedVersion(Int)
var errorDescription: String? {
switch self {
case .malformedJSON:
return "Could not parse the response."
case .unsupportedVersion(let v):
return "This data format (v\(v)) is not supported. Update the app."
}
}
}
The compiler enforces that callers acknowledge this error exists—they can't just ignore it. This explicitness is Swift's strength over silent exceptions.
throws, do, try, and Typed catch
A function that can fail is marked with throws (or throws(SpecificError) in Swift 6+). Inside, you mark calls to throwing functions with try. Outside, you wrap the call in do/catch:
do {
let user = try fetchUser(id: 123)
updateUI(with: user)
} catch NetworkError.timeout {
showError("Request timed out. Try again.")
} catch NetworkError.unreachable {
showError("No internet connection.")
} catch NetworkError.invalidResponse(let code) {
showError("Server error: \(code)")
} catch {
showError("Unknown error occurred.")
}
Each catch clause can match a specific error type or associated value. The final bare catch is a fallback. If you're catching multiple error types, you can pattern-match with catch let error as NetworkError:
do {
try operation()
} catch let error as NetworkError {
handleNetworkError(error)
} catch let error as ParseError {
handleParseError(error)
}
With typed throws (throws(NetworkError)), the catch cases are exhaustive—the compiler warns if you miss one. Without typed throws, you need a bare catch fallback.
Use pattern matching in catch to extract associated values cleanly: catch NetworkError.invalidResponse(let code) reads better than unwrapping after catching.
try?, try!, and When to Use Each
try? converts a throw into an Optional. If the function throws, the result is nil; if it succeeds, it's the value:
let userData = try? JSONDecoder().decode(User.self, from: data)
// userData is User? — nil if decoding threw
Use try? when you genuinely don't care why an operation failed, only whether it succeeded. But be careful: try? silently swallows the error. If you later need to know what went wrong, you've lost the information. A comment explaining why the error is ignorable is better than none:
// Try to load cached data; if it's corrupted, fall back to fetching.
let cached = try? loadCache()
let user = cached ?? (try await fetchFromServer())
try! is the force-unwrap of error handling—it crashes if the function throws:
let config = try! JSONDecoder().decode(Config.self, from: data)
// CRASHES if data is invalid
Only use try! when you are absolutely certain the operation cannot throw, or when a throw represents a programmer error. In shipped code, avoid it—it's a crash waiting to happen. In tests and setup code, it's acceptable because a crash signals a broken test.
try! crashes your app with no error message. If you're tempted to use it, ask: "Am I 100% sure this will never throw?" If not, use do/catch or try? instead.
defer: Cleanup and Resource Management
defer guarantees that a block of code runs when the scope exits—whether via return, throw, or reaching the end. Use it for cleanup:
func processFile(at path: String) throws {
let file = try openFile(path)
defer {
try? file.close() // Cleanup happens even if later code throws
}
let data = try file.read()
try validateData(data)
// If validateData throws, defer still runs before the error propagates
}
Multiple defer blocks run in reverse order (last registered, first executed). defer interacts with throwing: if defer itself throws, that error propagates and replaces any error that was already pending.
rethrows: For Higher-Order Functions
rethrows means "this function throws only if its parameter (a closure) throws." It's used in higher-order functions like map on throwing closures:
extension Array {
func mapThrowing<T>(_ transform: (Element) throws -> T) rethrows -> [T] {
var result = [T]()
for element in self {
result.append(try transform(element))
}
return result
}
}
// Caller doesn't mark this as try unless the closure throws
let numbers = [1, 2, 3]
let squared = try numbers.mapThrowing { n throws in
// Only requires try because the closure throws
}
Result<Success, Failure>: Storing and Passing Errors
Sometimes you need to store a failure or pass it around without immediately handling it. The Result enum works for this:
enum Result<Success, Failure: Error> {
case success(Success)
case failure(Failure)
}
// Instead of this:
func fetchUser(completion: @escaping (User?, Error?) -> Void) { ... }
// Do this:
func fetchUser(completion: @escaping (Result<User, NetworkError>) -> Void) { ... }
Result is cleaner for completion handlers and async operations. You can bridge back to throws with get():
let result: Result<User, NetworkError> = ...
do {
let user = try result.get()
updateUI(with: user)
} catch {
showError(error)
}
Use Result when you're storing a value for later, passing it across async boundaries, or building result chains with operators. For synchronous code, throws is usually simpler.
async/await and Typed Throws
In async code, async throws is the concurrent equivalent of throws:
func fetchUser(id: Int) async throws(NetworkError) -> User {
let response = try await urlSession.data(from: url)
return try parseJSON(response.0)
}
Task {
do {
let user = try await fetchUser(id: 123)
} catch NetworkError.timeout {
// Handle timeout
}
}
Task cancellation surfaces as CancellationError automatically—if a task is cancelled while awaiting, a CancellationError is thrown:
Task {
do {
let user = try await fetchUser(id: 123)
} catch is CancellationError {
// Task was cancelled
}
}
Respect cancellation by checking Task.isCancelled or catching CancellationError explicitly.
Typed Throws in Swift 6
Swift 6 introduced throws(ErrorType) to specify exactly which error type a function can throw:
func fetchData(from url: String) throws(NetworkError) -> String {
// Only NetworkError can be thrown; no other error type
}
do {
let data = try fetchData(from: "https://example.com")
} catch NetworkError.timeout {
// The compiler knows only NetworkError is possible
} catch NetworkError.unreachable {
// No "catch all" needed; exhaustiveness is guaranteed
}
The compiler enforces exhaustiveness without a bare catch. Typed throws also enable better error bridging in generic code and clearer API contracts. For internal APIs with stable error types, typed throws is a win. For public libraries or code that might evolve, untyped throws remains more flexible.
What do/catch Will Never Catch
This is the critical section. do/catch does NOT catch these:
- Nil unwraps (
let value = optional!) — crashes immediately, no error thrown - Force casts (
let x = y as! Type) — crashes immediately, no error thrown - Array index out of range — crashes, no error thrown
- Integer overflow — traps at runtime, no error thrown
- fatalError() and precondition() — intentional crashes, no error thrown
- Objective-C NSException (e.g.,
NSRangeException) — not bridged into Swift's error system. Ado/catcharound code that throwsNSRangeExceptionwill NOT save you.
These are all traps or crashes—the language gives up, not hands over control. A common surprise:
do {
let arr = [1, 2, 3]
let value = arr[10] // Crashes immediately — not caught!
} catch {
// This never runs
}
Even within a do block, forced unwraps and out-of-bounds access crash the app. If you call Objective-C code that raises an NSException, the Swift do/catch won't catch it. This is why crash reporting and iOS error monitoring are essential—those uncatchable crashes are exactly what crash reporters exist for. Link your app to the Sentry SDK pointing at LightTrace, and you'll see these crashes in your dashboard with full stack traces and context. Understanding how to read iOS crash logs is the next step.
NSException crashes (like NSRangeException from accessing an out-of-bounds index in Objective-C APIs) are NOT caught by Swift's do/catch. They're captured by crash reporters. See how NSRangeException happens for real-world examples.
Putting It Together: A Real Flow
Here's a realistic async file upload with layered error handling:
func uploadProfilePhoto(_ imageData: Data) async throws(UploadError) {
defer {
// Cleanup happens whether we succeed or throw
clearCache()
}
// Compression might fail; map the error
guard let compressed = try? compressImage(imageData) else {
throw UploadError.compressionFailed
}
// Network call with timeout handling
do {
try await api.upload(compressed)
} catch let error as NetworkError {
switch error {
case .timeout:
throw UploadError.timeout
case .unreachable:
throw UploadError.offline
default:
throw UploadError.networkFailure(error)
}
}
}
// Caller's code
Task {
do {
try await uploadProfilePhoto(imageData)
showSuccess("Photo uploaded!")
} catch UploadError.timeout {
showError("Upload timed out. Check your connection.")
} catch UploadError.offline {
showError("You're offline.")
} catch {
showError("Upload failed.")
}
}
This code: uses try? where compression failing is acceptable, maps lower-level errors to domain-specific ones, runs cleanup with defer, and gives the caller a clear error to handle.
Mastering do/try/catch means understanding that Swift's errors are not exceptions—they're explicit, typed, compiler-checked returns. Use the right tool: do/catch for known failures, try? for genuinely ignorable errors, defer for cleanup, Result for storing errors, and async throws for concurrent code. Know what won't be caught (force unwraps, Objective-C exceptions, runtime traps), and wire those crashes to error tracking so you can find and fix them.
Start tracking errors in minutes
Start tracking iOS crashes including NSException and unhandled errors today—point your Sentry SDK at LightTrace and get full stack traces in minutes. Start free.