
17 Minutes
Swift Error Handling: Try, Throw & Do-Catch Explained
Fix Bugs Faster! Log Collection Made Easy
Introduction to Swift error handling
As developers, we know errors are unavoidable. Invalid inputs, failed network calls, or missing files can break even the most polished apps. Thankfully Swift provides a robust error handling system (also called exception handling) to help us detect, propagate, and recover from these issues gracefully.
In this guide, we’ll explore:
- Common causes and error types in Swift
- Defining and throwing errors with enums and the
throw
keyword - Handling errors using
try
,try?
,try!
, anddo-catch
- Best practices for writing clear, maintainable error handling code
- Advanced techniques like typed throws,
defer
, async handling, and protocols - A practical example: building a simple network client.
Table of Contents
- Introduction to Swift error handling
- Common causes and error types in Swift
- Defining custom errors with enums in Swift
- Throwing errors in Swift with the throw keyword
- How to handle errors in Swift
- Best practices for error handling in Swift
- Swift error handling example: simple network client
- Advanced techniques for error handling in Swift
- To sum up Swift error handling
Common causes and error types in Swift
Errors in Swift can come from lots of different sources. Some need fixes in our code, others can be handled at runtime with retries, defaults, or better input checks. Here are some typical examples:
Cause | Error | Type & Where to Act |
---|---|---|
User entered invalid data (divide by zero, bad format) | Invalid input | Runtime / logic error → validate input before using it |
File can’t be found or read (missing, no permission) | File error | I/O error → check file paths, permissions, or add defaults |
Network fails (timeout, offline, bad response) | Network error | Connectivity error → retry requests or show user-friendly messages |
Code mistake (unexpected value, nil where needed) | Logic error | Program bug → fix in code with guards, tests, or better design |
Custom rules (e.g. payment declined, stock empty) | Custom error | App-defined → decide app behavior (retry, show alert, etc.) |
Now, let’s look at how to fix these errors – starting with how to categorize them.
Defining custom errors with enums in Swift
In Swift, we usually create our own error types to describe the problems our code might run into. To do this, we define an enum
that conforms to the Error
protocol. Each case in the enum represents a specific error situation. This makes errors easy to identify, handle, and extend later as your app grows. Learn more in our guide to Swift enums.
enum FileError: Error {
case fileNotFound
case permissionDenied
case writeFailed
}
Instead of throwing a generic error, we can throw one of these cases. This gives us clear, meaningful errors (like fileNotFound
), which can be caught and handled in different ways depending on the situation.
Throwing errors in Swift with the throw keyword
One of the most important considerations when handling errors is the ability to throw them, which essentially means the way we let our methods know an error has occurred.
In Swift we can do this by using the throws
keyword to indicate that a function can throw an error, and the throw
statement inside the function to actually throw the error.
As an example, we’ve set up a simple method to open a file and write to it, then throw any errors as appropriate:
enum FileError: Error {
case fileNotFound
case permissionDenied
}
func writeToFile(named filename: String) throws {
// Read file
if fileNotFound {
throw FileError.fileNotFound
}
// Write to file
if permissionsDenied {
throw FileError.permissionDenied
}
}
This is obviously a very simple example, but we can see how errors are thrown and how we throw a different error based on the error condition.
How to handle errors in Swift
Now, let’s get into the stuff you came to read. We’re about to really get into the weeds of Swift-error handling… let’s do it!
The differences between try
, try?
and try!
Swift gives us several ways to deal with errors when calling a throwing function. The try
family (try
, try?
, and try!
) decides what happens if something goes wrong: do we pass the error up, turn it into nil
, or crash the program if we’re sure it can’t fail?
On top of the try
resources, Swift provides the do-catch
statement, which lets us wrap risky code in a safe block and react if an error is thrown. Together, these tools form the core of error handling in Swift.
Keyword | Behavior | When to Use |
---|---|---|
try | Propagates the error to be handled elsewhere with do-catch | Standard choice when you want to handle or pass on errors |
try? | Converts errors into nil (optional value) | When failure isn’t critical and you’re fine with a default or no value |
try! | Forces execution, crashes if an error occurs | Only when you’re absolutely certain no error can happen (e.g. bundled resource) |
Using try in Swift with throwing functions
When a function is marked with throws
, we must call it with the try
keyword. This marks the call as one that can fail and ensures the error will either be handled later or passed up to the caller. On its own, try
doesn’t stop or fix anything — it simply warns that the function may throw.
func myMethod() throws {
try writeToFile(named: "myFileName.txt")
// Success code here runs only if no error is thrown
}
Here, myMethod
also uses throws
, which means it doesn’t catch the error itself. To actually handle errors, we’ll need a do-catch
block — that’s the next step.
Handling errors in Swift using do-catch
Even when we’ve marked a function call with try
, we still need a way to respond if it fails. The most common approach is the do-catch
statement, which runs our code inside a do
block and reacts with catch
if an error is thrown.
func myMethod() {
//...
do {
try writeToFile(named: "myFileName.txt")
// Success code, after writing to the file
} catch let error {
print(error)
}
}
Here, any error from writeToFile
is caught and handled safely. We can also add multiple catch
clauses to handle different error types — we’ll explore this later with real networking examples.
All good so far? Cool. Now, let’s take the next step and look at how Swift propagates errors.
Converting errors to optionals with try?
try?
is useful when you don’t need to handle an error in detail. Instead of throwing, it converts any failure into nil
(no value). This way, if something goes wrong, your program continues running without crashing.
if let message = try? saveMessage("Hello, world!") {
print("Message saved:", message)
} else {
print("Message could not be saved.")
}
If the function succeeds, message
contains the result and the success branch runs. If it fails, message
becomes nil
and the else
branch handles the failure gracefully.
Disabling error propagation with try!
In Swift, the try!
keyword is used when you know for sure a throwing function will not fail. Unlike try?
, which safely returns nil
if an error occurs, try!
disables error propagation and will crash the app if an error is actually thrown. This makes it risky, but useful in cases where failure is impossible (e.g., loading a bundled file you know exists).
Always use try!
with caution, and only when you’re certain the call cannot fail.
try! writeToFile(named: "myFileName.txt")
Best practices for error handling in Swift
Ok, so we’ve covered the basic tools for error handling in Swift, and shown how powerful the toolkit is. But writing reliable code means more than just using try
or do-catch
. Following best practices helps keep our code maintainable, predictable, and user-friendly.
As well as mastering the resources mentioned above, we’d advise you to consider some key points:
- Propagate errors at the right level instead of handling them everywhere.
- Write clear and meaningful messages to help debugging and users.
- Use
try?
only for simple, non-critical failures. - Add
guard
statements for clean, early exits when something goes wrong. - Test and plan for failure cases so you know your app won’t break unexpectedly.
Propagate errors at the right level
In Swift, we don’t need to handle every error where it occurs. Instead, let errors bubble up until they reach the part of our code that actually knows what to do. This keeps our lower-level methods clean and centralizes decision-making in one place.
enum FileError: Error {
case fileNotFound
case permissionDenied
case writeFailed
}
func writeToFile() throws {
// simulate different errors
throw FileError.fileNotFound
}
func saveDocument() throws {
try writeToFile()
}
func performSave() {
do {
try saveDocument()
} catch FileError.fileNotFound {
print("Error: The file could not be found.")
} catch FileError.permissionDenied {
print("Error: You don’t have permission to save here.")
} catch FileError.writeFailed {
print("Error: Something went wrong while writing.")
} catch {
print("Unexpected error:", error)
}
}
Here, writeToFile
throws, saveDocument
just passes the error up, and all handling happens in performSave
. This way we keep the handling logic in one place and avoid repeating it in every function.
Write clear and meaningful error messages
Errors should explain what went wrong, not leave people guessing. Compare these two approaches:
❌ Bad example – vague and unhelpful:
catch {
print("Error occurred")
}
✅ Good example – clear and actionable:
catch FileError.fileNotFound {
print("Error: The file could not be found at the given path.")
} catch FileError.permissionDenied {
print("Error: You don’t have permission to save this file.")
} catch {
print("Unexpected error:", error)
}
The second example makes debugging easier and gives users messages they can actually understand and act on.
When to use try?
for simple, non-critical failures
try?
works well when errors aren’t critical and you can continue without them. Instead of stopping the program, failures become nil
and can be ignored or replaced with a default.
Good Situations (use try? ) | Bad Situations (avoid try? ) |
---|---|
Loading a cached file that may not exist | Processing payments or financial transactions |
Playing a sound effect that might be missing | Saving critical user data (documents, backups) |
Parsing optional metadata from a network response | Handling authentication (e.g. login, API keys) |
Fetching non-essential settings or preferences | Writing to a database or permanent storage |
Displaying a preview image that might fail to load | Performing file operations where data loss is possible |
With try?
, use it only when ignoring the error is safe and won’t harm the user experience.
Using guard for early error handling
Another effective way to handle errors in Swift is with guard
. A guard allows us to check for problems early, and if something goes wrong, exit the scope immediately. This keeps our code clean and avoids deep nesting. To use it, we can slightly adjust our throwing function to return a Bool
:
func writeToFile(named filename: String) throws -> Bool {
// Read file
if fileNotFound {
throw FileError.fileNotFound
}
// Write to file
if permissionsDenied {
throw FileError.permissionDenied
}
}
Now we can guard against failure like this:
guard try writeToFile(named: "myFileName.txt") else {
// Here we would need to exit scope, usually with a return
}
// on a successful file write, the code here would execute
Test and plan for failure cases
We should always test what happens when things fail, not just when they work. By simulating common errors, we make sure our app stays stable and gives users clear feedback.
Failure | What to test | How to handle |
---|---|---|
Missing file | Try to open a file that doesn’t exist | Show a message or create a new file |
No permission | Write to a protected folder | Stop and tell the user |
Bad data | Parse invalid JSON | Fail safely and use a default value |
No internet | Make a request offline | Retry later or show offline mode |
Wrong input | Divide by zero | Catch the error and prevent a crash |
Swift error handling example: simple network client
The best way to understand is to put the theory into practice with a working example and we’re going to setup a simple network client to demonstrate. Ready? Let’s go…
Initial setup
Our RequestClient
will initially only have an init
that accepts a URLSession
, and if none is provided it uses the shared default as shown below:
final class RequestClient {
private let session: URLSession
init(_ session: URLSession = URLSession.shared) {
self.session = session
}
}
Making requests
Now let’s add a method to allow us to make requests. We’ll accept the method type, defaulting to GET
, the URL
string, a body and headers. It will have a completion block that will use the previously seen Result
type, to either return the data from our request or an error.
Our method is as follows:
func perform(method: String = "GET",
onURL urlString: String,
withBody body: [String: AnyObject] = [:],
withHeaders headers: [String: String] = [:],
completion: @escaping (Result<Data, Error>) -> Void) {
guard let url = URL(string: urlString) else {
completion(.failure()) // 1
return
}
var request = URLRequest(url: url)
request.httpMethod = method
headers.forEach { (key: String, value: String) in
request.setValue(value, forHTTPHeaderField: key)
}
do {
let jsonifiedBody = try JSONSerialization.data(withJSONObject: body)
request.httpBody = jsonifiedBody
} catch let error {
completion(.failure()) // 2
}
URLSession.shared.dataTask(with: request) { data, response, error in
if error != nil {
completion(.failure()) // 3
return
}
if let response = response as? HTTPURLResponse, let data = data {
switch response.statusCode {
case 200...299:
completion(.success(data))
case 400...499:
completion(.failure()) // 4
case 500...599:
completion(.failure()) // 4
default:
completion(.failure()) // 4
} else {
completion(.failure()) // 4
}
}
}
There are plenty of points at which it can fail and tell the method caller that an error occurred, these are:
- When trying to create a
URL
from the receivedString
- When turning the body into a jsonified body to be sent with the request
- When an error occurs with the request itself
- When any status code received is not in the acceptable range we’re expecting
💡 Learn more about status codes and their meanings here.
Now, as we already know all the possible errors our method may need to identify, we can now create our Error
enum. For cases two and three, an error is already being thrown locally so our Error
should take that as an associated value.
💡 Learn more about enums and associated values in our article here.
Similarly, since we already have error codes in scenario four, we can use those codes as associated values, as shown here:
enum RequestError: Error {
case invalidURL
case invalidBodyFormat(error: Error)
case requestError(error: Error)
case invalidRequest(code: Int)
case serverError(code: Int)
case unknownError(code: Int)
case wrongResponseFormat
}
Now we have all our possible errors, we can see how our RequestClient
looks when everything is in place:
final class RequestClient {
private let session: URLSession
init(_ session: URLSession = URLSession.shared) {
self.session = session
}
func perform(method: String = "GET",
onURL urlString: String,
withBody body: [String: AnyObject] = [:],
withHeaders headers: [String: String] = [:],
completion: @escaping (Result<Data, Error>) -> Void) {
guard let url = URL(string: urlString) else {
completion(.failure(RequestError.invalidURL))
return
}
var request = URLRequest(url: url)
request.httpMethod = method
headers.forEach { (key: String, value: String) in
request.setValue(value, forHTTPHeaderField: key)
}
do {
let jsonifiedBody = try JSONSerialization.data(withJSONObject: body)
request.httpBody = jsonifiedBody
} catch let error {
completion(.failure(RequestError.invalidBodyFormat(error: error))
}
URLSession.shared.dataTask(with: request) { data, response, error in
if error != nil {
completion(.failure(RequestError.requestError(error: error!)))
return
}
if let response = response as? HTTPURLResponse, let data = data {
switch response.statusCode {
case 200...299:
completion(.success(data))
case 400...499:
completion(.failure(RequestError.invalidRequest(code: response.statusCode)))
case 500...599:
completion(.failure(RequestError.serverError(code: response.statusCode))
default:
completion(.failure(RequestError.unknownError(code: response.statusCode))
} else {
completion(.failure(RequestError.wrongResponseFormat))
}
}
}
}
While we’re using it as an example, it is a legitimate request maker class that you could use, so long as you make sure to assign the errors with names errors that make sense in your own scope.
And that’s it – our client is now ready to use and will run like this:
let client = RequestClient()
client.perform(method: "GET",
onURL: "<https://httpbin.org/get>") { [weak self] result in
switch result {
case .success(let data):
// handle our data
case .failure(let error):
// handle our error here.
// Show an alert to the user if appropriate.
// Log the error into our favourite logging framework,
// like BugFender.
}
}
Advanced techniques for error handling in Swift
Beyond the basics, Swift gives us powerful features to handle errors more precisely and write cleaner code. These techniques are especially useful in larger apps where stability matters most:
- Typed throws – restrict functions to throw only certain error types
defer
keyword – ensure cleanup code always runs before exiting a scope- Async error handling – use
Result
andDispatchGroup
to manage failures in concurrent code - Optional chaining (
?.
) and nil-coalescing (??
) – simplify error-prone code with safe defaults - Protocols with error handling – enforce consistent strategies across multiple types
Limiting errors with typed throws
By default, any function marked with throws
can throw any error type, which sometimes makes it harder to know what to handle. With typed throws, we restrict a function to a specific error type, so the caller knows exactly what failures to expect.
enum MathError: Error {
case divideByZero
}
func divide(_ a: Int, _ b: Int) throws(MathError) -> Int {
guard b != 0 else { throw .divideByZero }
return a / b
}
Being explicit is most useful when:
- A function has predictable, limited failure modes (like division by zero).
- You want stronger compile-time checks and avoid catching unrelated errors.
Async error handling with Result
type
The Result
type provides a clean way to manage both success and failure in async operations. It’s particularly useful in network calls, where outcomes are often unpredictable.
For example, here’s a simple parseDataToUser
function that pretends to parse a Data
object and either returns a User
or an error:
func parseDataToUser(dataObject: Data, result: (Result<User, Error>) -> Void) {
//here it would try to parse said data
if anyError {
result(.failure(myError))
}
result(.success(myParsedUser))
}
Now we could use this method with Result
type support like this:
parseDataToUser(dataObject: ourDataObject) { result in
switch result {
case .success(let user): break
case .failure(let error): break
}
}
This approach makes both paths explicit. By switching on .success
or .failure
, we gain clarity and can return meaningful messages instead of leaving errors hidden.
Coordinating async tasks with DispatchGroup
Sometimes we need to run multiple async tasks, like making several network requests, and only proceed once all have finished. Swift’s DispatchGroup
helps us coordinate this. For example, imagine fetching user data and messages at the same time, then updating the UI only when both are ready:
let group = DispatchGroup()
group.enter()
fetchUserData { group.leave() }
group.enter()
fetchMessages { group.leave() }
group.notify(queue: .main) {
print("User data and messages loaded")
}
This way we avoid partial results and ensure errors or updates are handled only after all tasks complete.
Optional chaining with ?.
Optional chaining lets us safely access properties or methods on optionals without crashing. Instead of forcing unwrapping, Swift returns nil
if the value doesn’t exist.
Example: user?.profile?.email
- Returns the email if all values exist
- Returns
nil
if any step in the chain fails
This allows us to:
- Avoid runtime crashes by skipping forced unwrapping
- Cut down repetitive
if let
orguard
checks - Handle deeply nested optionals more easily
- Keep code shorter, safer, and easier to read
Nil-coalescing with ??
The nil-coalescing operator lets us provide a default value when an optional is nil
. For example, let name = user.name ?? "Guest"
assigns "Guest"
if no name exists.
This allows us to:
- Guarantee a fallback value when something is missing
- Simplify error handling for optional values
- osImprove user experience with friendly defaults (like “Guest”)
- Keep code concise and readable without extra checks
To sum up Swift error handling
Good error handling isn’t just about avoiding crashes, it’s about keeping apps stable, users happy, and debugging time under control. In this guide we’ve seen how to define, throw, and catch errors, when to use try
, try?
, or try!
, and how to centralize handling so our code stays clean.
We also built a simple RequestClient
to show how these techniques work in practice.
And here’s the real win: combine these approaches with proper monitoring. With tools like Bugfender for iOS, you can capture crash reports, get notified in real time, and fix issues before users even complain. That’s how you move from firefighting bugs to building rock-solid apps.
Expect The Unexpected!
Debug Faster With Bugfender