Error Handling in Swift Made Simple

Error Handling in Swift Made Simple

Error handling is one of those topics that looks small at first glance, then quietly becomes one of the most important parts of writing solid Swift code. At the beginning, it often feels like something you only need when a network request fails or a file is missing. But the more you build real apps, the more you realize that error handling is not a side feature. It is part of the design of your app.

A good error-handling strategy makes your code easier to read, easier to test, and much easier to trust. A poor one turns your app into a maze of force unwraps, silent failures, and confusing crash reports. Swift gives you a clean, expressive, and type-safe way to deal with errors, and once it clicks, it becomes one of the nicest parts of the language.

In this article, we will go step by step through Swift error handling in a practical way. We will look at throwing functions, do-catch, custom error types, try?, try!, Result, and even how to handle errors in async code. We will also talk about the habits that make your code calmer and more maintainable in the long run.

Why error handling matters so much

Every app talks to the outside world in some way. It reads files, parses JSON, hits APIs, saves data, checks permissions, and depends on user input. And the outside world is messy.

A file may not exist.

A server may be down.

A password may be invalid.

A number may be out of range.

A JSON payload may not match the model you expected.

These are not rare edge cases. They are normal cases.

Swift’s error handling gives you a way to say: this code can fail, and here is how that failure should be handled. That simple idea changes how you think about your code. Instead of hiding failure, you model it clearly.

The basic idea behind Swift errors

Swift uses throw, try, and catch to work with errors.

A function that can fail is marked with throws. Inside that function, you can use throw to stop execution and send an error upward.

Here is the simplest possible example:

enum SimpleError: Error {
    case somethingWentWrong
}

func doWork() throws {
    throw SimpleError.somethingWentWrong
}

Because doWork() can throw, any place that calls it must either handle the error or pass the responsibility upward.

do {
    try doWork()
    print("Work completed")
} catch {
    print("An error happened: \(error)")
}

That is the core pattern. A throwing function reports failure, and the caller decides what to do.

Understanding the Error protocol

In Swift, most errors conform to the Error protocol. It does not require you to implement anything. It simply marks a type as something that can represent an error.

Usually, you use an enum for errors because it is clear and readable.

enum LoginError: Error {
    case emptyEmail
    case emptyPassword
    case invalidCredentials
}

An enum is often better than a string because it is typed, predictable, and easy to switch over.

You can also attach values to errors when needed:

enum NetworkError: Error {
    case invalidURL(String)
    case serverError(statusCode: Int)
    case decodingFailed(reason: String)
}

This makes errors more useful. Instead of saying only “something failed,” your code can say exactly what failed and why.

Creating throwing functions

A throwing function is just a function marked with throws.

func divide(_ a: Int, by b: Int) throws -> Int {
    guard b != 0 else {
        throw MathError.divideByZero
    }
    return a / b
}

enum MathError: Error {
    case divideByZero
}

Now the caller must deal with the possibility of failure.

do {
    let result = try divide(10, by: 2)
    print(result)
} catch {
    print("Could not divide: \(error)")
}

This is much better than pretending division by zero cannot happen.

Using do-catch

The do-catch block is the main tool for handling thrown errors.

do {
    try someThrowingFunction()
} catch {
    print("Caught error: \(error)")
}

But catch can do more than catch everything generically. You can match specific error cases.

enum FileError: Error {
    case fileNotFound
    case noPermission
    case corruptedFile
}

func readFile() throws {
    throw FileError.fileNotFound
}

do {
    try readFile()
} catch FileError.fileNotFound {
    print("The file does not exist.")
} catch FileError.noPermission {
    print("You do not have permission to read this file.")
} catch {
    print("A different error occurred: \(error)")
}

This gives you fine-grained control. The code becomes more expressive, and your error messages become more useful.

Custom errors with LocalizedError

Sometimes you want your errors to carry a user-friendly message.

Swift provides the LocalizedError protocol for that.

enum ProfileError: Error {
    case invalidName
    case invalidAge
}

extension ProfileError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .invalidName:
            return "The name you entered is not valid."
        case .invalidAge:
            return "The age must be greater than zero."
        }
    }
}

Now your UI code can present a meaningful message:

do {
    try validateProfile(name: "", age: -1)
} catch {
    print(error.localizedDescription)
}

That separation is helpful. Your error type holds the meaning, and your UI decides how to present it.

Validating input the Swift way

Validation is one of the most natural places to use throwing functions.

enum RegistrationError: Error {
    case emptyUsername
    case shortPassword
    case invalidEmail
}

func validateRegistration(username: String, password: String, email: String) throws {
    guard !username.isEmpty else {
        throw RegistrationError.emptyUsername
    }

    guard password.count >= 8 else {
        throw RegistrationError.shortPassword
    }

    guard email.contains("@") else {
        throw RegistrationError.invalidEmail
    }
}

Usage:

do {
    try validateRegistration(username: "hassan", password: "12345678", email: "hassan@example.com")
    print("Validation passed")
} catch {
    print("Validation failed: \(error)")
}

This is clean because validation failures are not unusual logic branches buried in if statements. They are explicit failures that the caller must consider.

The difference between try, try?, and try!

These three forms are important.

try means: this can throw, and I will handle that possibility.

try? means: if it fails, return nil instead of throwing.

try! means: I believe this will never fail, and if it does, crash the app.

try

do {
    let value = try divide(8, by: 2)
    print(value)
} catch {
    print(error)
}

try?

let value = try? divide(8, by: 0)
print(value as Any) // nil

This is useful when the exact failure does not matter and nil is acceptable.

try!

let value = try! divide(8, by: 2)
print(value)

try! should be used carefully. It is fine in narrow situations where failure is truly impossible and the code would be broken otherwise. In most app code, it is safer to avoid it.

A good habit is this: use try! only when a crash would reveal a programming mistake, not a runtime condition.

Optional vs throwing: when to use which

Swift gives you two common ways to represent failure: optionals and thrown errors.

Use an optional when the only information you need is “value or no value.”

func findUsername() -> String? {
    return nil
}

Use a throwing function when you need to know why something failed.

func loadUserProfile() throws -> UserProfile {
    throw ProfileError.invalidName
}

That distinction matters.

If nil is enough, an optional keeps things lightweight.

If the caller needs context or recovery logic, a thrown error is better.

A practical file-reading example

Let us make the idea more concrete.

Imagine a function that reads text from a file.

enum ReadError: Error {
    case fileMissing
    case unreadableContent
}

func readTextFile(named name: String) throws -> String {
    guard !name.isEmpty else {
        throw ReadError.fileMissing
    }

    let content = "Hello from file"

    guard !content.isEmpty else {
        throw ReadError.unreadableContent
    }

    return content
}

Then handle it:

do {
    let text = try readTextFile(named: "notes.txt")
    print(text)
} catch ReadError.fileMissing {
    print("The file name was empty or the file could not be found.")
} catch ReadError.unreadableContent {
    print("The file exists, but it could not be read.")
} catch {
    print("Unexpected error: \(error)")
}

Even though this example is simple, it reflects how real programs should feel. Clear, honest, and easy to reason about.

Propagating errors upward

One of Swift’s strengths is that you do not need to catch every error right away. You can pass the responsibility upward.

func loadData() throws {
    try readTextFile(named: "config.txt")
}

func startApp() throws {
    try loadData()
}

This is useful because the lowest-level function may detect the problem, but the higher-level function may be the one that knows how to respond.

For example, a network layer may detect a timeout, but the UI layer may decide to show a retry button.

That separation helps keep code organized.

Building your own error hierarchy

As your app grows, you may end up with many different error types. It helps to organize them.

enum AppError: Error {
    case authentication(AuthError)
    case network(NetworkError)
    case database(DatabaseError)
}

enum AuthError: Error {
    case invalidPassword
    case userNotFound
}

enum NetworkError: Error {
    case noInternet
    case timeout
    case invalidResponse
}

enum DatabaseError: Error {
    case saveFailed
    case recordNotFound
}

This approach gives you structure. You can group related errors and keep them meaningful.

Later, if needed, you can switch on the outer error and then inspect the inner one.

func handle(error: AppError) {
    switch error {
    case .authentication(let authError):
        print("Auth problem: \(authError)")
    case .network(let networkError):
        print("Network problem: \(networkError)")
    case .database(let databaseError):
        print("Database problem: \(databaseError)")
    }
}

This works especially well in medium and large apps.

Error handling in networking

Networking is one of the most common places where error handling becomes real.

A request may fail because:

  • the URL is invalid

  • there is no internet connection

  • the server returns a bad status code

  • the response data cannot be decoded

Here is a realistic example:

import Foundation

enum APIError: Error {
    case invalidURL
    case noData
    case badStatusCode(Int)
    case decodingFailed
}

struct User: Decodable {
    let id: Int
    let name: String
}

func fetchUser() async throws -> User {
    guard let url = URL(string: "https://example.com/user") else {
        throw APIError.invalidURL
    }

    let (data, response) = try await URLSession.shared.data(from: url)

    guard let httpResponse = response as? HTTPURLResponse else {
        throw APIError.noData
    }

    guard 200...299 == httpResponse.statusCode else {
        throw APIError.badStatusCode(httpResponse.statusCode)
    }

    do {
        let user = try JSONDecoder().decode(User.self, from: data)
        return user
    } catch {
        throw APIError.decodingFailed
    }
}

Then you can handle it like this:

Task {
    do {
        let user = try await fetchUser()
        print("User:", user.name)
    } catch APIError.invalidURL {
        print("The API URL is invalid.")
    } catch APIError.badStatusCode(let code) {
        print("Server returned status code \(code).")
    } catch APIError.decodingFailed {
        print("Could not decode user data.")
    } catch {
        print("Unexpected error: \(error)")
    }
}

This is much better than allowing a networking failure to become a mystery.

Async/await and error handling

Modern Swift often uses async code, and the good news is that error handling stays elegant.

A function can be both async and throws.

func loadProfile() async throws -> String {
    throw ProfileError.invalidName
}

You call it like this:

Task {
    do {
        let profile = try await loadProfile()
        print(profile)
    } catch {
        print(error)
    }
}

The order is always try await when calling an async throwing function.

This is one of those syntax details that becomes second nature quickly.

Using Result

Sometimes, instead of throwing, you may want to return a Result.

Result is an enum that represents either success or failure.

enum LoginError: Error {
    case wrongPassword
    case accountLocked
}

func login(username: String, password: String) -> Result<String, LoginError> {
    if password == "1234" {
        return .success("Welcome, \(username)")
    } else {
        return .failure(.wrongPassword)
    }
}

Usage:

let result = login(username: "hassan", password: "0000")

switch result {
case .success(let message):
    print(message)
case .failure(let error):
    print("Login failed: \(error)")
}

Result is useful when you need to pass success or failure as a value, especially in completion handlers or asynchronous APIs.

That said, in modern Swift, throws is often cleaner when you control the function signature yourself. Result is still very useful, but not always necessary.

Converting between throwing functions and Result

You may sometimes need to turn a throwing function into a Result.

func performOperation() throws -> Int {
    return 42
}

let result = Result {
    try performOperation()
}

And you can turn a Result back into a throwing flow:

func useResult() throws -> Int {
    let result = Result { try performOperation() }
    return try result.get()
}

This flexibility is one of the reasons Result fits well into the Swift ecosystem.

Best practices for designing errors

A good error type should be:

clear,
specific,
and useful.

Here are some habits that help.

1. Use enums for expected failures

If you know the possible failures ahead of time, an enum is ideal.

enum PaymentError: Error {
    case insufficientFunds
    case invalidCard
    case networkUnavailable
}

2. Make error names meaningful

Avoid vague names like SomethingWrongError.

Prefer names that describe the domain.

AuthenticationError is better than GeneralError.

3. Keep the error close to the feature

If an error is only relevant to image uploading, keep it near that code. Do not throw everything into one giant global error enum unless it truly belongs there.

4. Avoid overusing try!

It is tempting, especially in early prototypes. But production code deserves better than silent assumptions.

5. Do not swallow errors

Catching an error and doing nothing is almost always a bad idea.

do {
    try saveData()
} catch {
    // nothing here
}

This makes failures invisible. At least log the error, present feedback, or make a deliberate decision.

6. Convert errors into helpful messages at the boundary

Low-level code should stay technical. UI code should turn those technical errors into human language.

That keeps your architecture cleaner.

Logging errors thoughtfully

Sometimes the right response to an error is not to show it directly to the user, but to log it.

do {
    try saveSettings()
} catch {
    print("Failed to save settings: \(error)")
}

In a real app, this might go to your logging system rather than print.

A useful approach is to log the technical details for developers and show a simple message to users.

For example:

do {
    try syncData()
} catch {
    logger.error("Sync failed: \(error.localizedDescription)")
    showAlert("We could not sync your data right now.")
}

That balance matters. Users need clarity, not stack traces.

Turning errors into user-friendly alerts

Here is a practical UI example.

func showError(_ error: Error) {
    let message: String

    if let localizedError = error as? LocalizedError,
       let description = localizedError.errorDescription {
        message = description
    } else {
        message = "Something went wrong."
    }

    print("Alert message:", message)
}

This works nicely because many of your custom error types can adopt LocalizedError.

For example:

enum UploadError: Error {
    case fileTooLarge
    case unsupportedFormat
}

extension UploadError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .fileTooLarge:
            return "The file is too large to upload."
        case .unsupportedFormat:
            return "This file format is not supported."
        }
    }
}

Now your error handling can support both developers and users.

Common mistakes beginners make

Error handling in Swift is simple once you see the pattern, but a few traps show up often.

Mistake 1: Using try! everywhere

This is risky because it turns recoverable failures into crashes.

Mistake 2: Catching everything without action

A catch block should do something meaningful.

Mistake 3: Returning vague errors

An error like case failed gives almost no useful information.

Mistake 4: Throwing when optional would be enough

Not every missing value needs an error. Use the simplest tool that fits the situation.

Mistake 5: Overcomplicating error types

A huge error enum with dozens of unrelated cases can become harder to manage than the code it was meant to improve.

Good error handling should reduce stress, not add it.

A complete example: a small password validator

Let us build a slightly fuller example that shows the whole flow.

enum PasswordError: Error {
    case tooShort
    case missingUppercase
    case missingNumber
}

extension PasswordError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .tooShort:
            return "The password must be at least 8 characters long."
        case .missingUppercase:
            return "The password must include at least one uppercase letter."
        case .missingNumber:
            return "The password must include at least one number."
        }
    }
}

func validatePassword(_ password: String) throws {
    guard password.count >= 8 else {
        throw PasswordError.tooShort
    }

    guard password.contains(where: { $0.isUppercase }) else {
        throw PasswordError.missingUppercase
    }

    guard password.contains(where: { $0.isNumber }) else {
        throw PasswordError.missingNumber
    }
}

Usage:

let password = "swiftcode"

do {
    try validatePassword(password)
    print("Password is valid.")
} catch {
    print(error.localizedDescription)
}

This style is excellent because the validation logic reads almost like English. The moment a rule fails, the code stops and says exactly what went wrong.

A complete example: decoding JSON with meaningful errors

JSON decoding is another place where error handling matters a lot.

import Foundation

enum DecodeError: Error {
    case invalidData
    case decodingFailed
}

struct Article: Decodable {
    let title: String
    let author: String
}

func decodeArticle(from data: Data) throws -> Article {
    guard !data.isEmpty else {
        throw DecodeError.invalidData
    }

    do {
        return try JSONDecoder().decode(Article.self, from: data)
    } catch {
        throw DecodeError.decodingFailed
    }
}

Usage:

let json = """
{
    "title": "Error Handling in Swift",
    "author": "Hassan"
}
""".data(using: .utf8)!

do {
    let article = try decodeArticle(from: json)
    print(article.title)
} catch {
    print("Failed to decode article: \(error)")
}

This is a simple but realistic pattern. It allows you to distinguish between invalid input and actual decoding failure.

Error handling with guard

guard and throw pair very well.

func processAge(_ age: Int) throws {
    guard age > 0 else {
        throw RegistrationError.invalidAge
    }

    print("Age is valid: \(age)")
}

This style keeps your logic flat and readable. Instead of nesting if statements, you handle invalid conditions early and continue with the happy path.

That is one of the nicest things about Swift: the code can stay readable even as you add safety.

When not to throw

Not every failure should be modeled with an error.

Sometimes returning a Boolean is enough.

func isPasswordStrong(_ password: String) -> Bool {
    return password.count >= 8
}

Sometimes returning an optional is enough.

func firstName(from fullName: String) -> String? {
    return fullName.split(separator: " ").first.map(String.init)
}

And sometimes throwing is the right move.

func loadRemoteConfig() throws -> Config {
    throw NetworkError.timeout
}

A simple guideline helps here: use the smallest abstraction that still communicates the failure clearly.

Building a calm mindset around errors

A lot of people think errors are a sign that something is broken. In reality, errors are part of normal software behavior.

A healthy app does not pretend errors do not exist. It expects them, names them, and handles them with care.

That is a useful mental shift.

Instead of thinking, “How do I avoid failures?” ask, “How do I make failures visible and manageable?”

That question leads to better architecture.

It also makes debugging less painful, because your code tells you what happened instead of hiding it.

Final thoughts

Swift’s error handling is elegant because it is honest. It does not hide failure behind crashes or vague return values. It lets you describe what can go wrong, where it can go wrong, and how the rest of your code should respond.

Once you get comfortable with throws, try, do-catch, custom errors, try?, and Result, your code starts to feel more deliberate. You write fewer hacks, fewer force unwraps, and fewer “this should never happen” shortcuts. Your app becomes more stable, and your future self will thank you for it.

The real goal of error handling is not just to catch problems. It is to design systems that behave well when problems appear. That is what makes Swift such a pleasant language to build with: it gives you the tools to do that without making your code ugly.