Swiftyn LogoSwiftyn
LearnInterview PrepRoadmapsArchitect Profile
Swift LanguageSwiftUIUIKitiOS ConceptsmacOS

Swift Language Topics

Introduction to SwiftVariables and ConstantsData TypesType InferenceOperatorsStrings and CharactersBooleansTuplesIf Else StatementsSwitch StatementsGuard StatementsLoopsBreak and ContinueArraysDictionariesSetsCollection OperationsFunctionsFunction ParametersReturn TypesInout ParametersVariadic ParametersClosuresTrailing ClosuresEscaping ClosuresAuto ClosuresCapture ListsOptionalsOptional BindingNil CoalescingOptional ChainingImplicitly Unwrapped OptionalsStructuresClassesPropertiesComputed PropertiesProperty ObserversMethodsInitializationDeinitializationInheritancePolymorphismEncapsulationAccess ControlStatic vs Class MethodsProtocolsProtocol ExtensionsProtocol CompositionAssociated TypesExtensionsGenericsGeneric ConstraintsOpaque TypesExistential TypesType CastingAny and AnyObjectNested TypesSubscriptsKeyPathsThrowing FunctionsDo Try CatchCustom ErrorsResult TypeARCStrong ReferencesWeak ReferencesUnowned References
Browse Swift Language Topics
Introduction to SwiftVariables and ConstantsData TypesType InferenceOperatorsStrings and CharactersBooleansTuplesIf Else StatementsSwitch StatementsGuard StatementsLoopsBreak and ContinueArraysDictionariesSetsCollection OperationsFunctionsFunction ParametersReturn TypesInout ParametersVariadic ParametersClosuresTrailing ClosuresEscaping ClosuresAuto ClosuresCapture ListsOptionalsOptional BindingNil CoalescingOptional ChainingImplicitly Unwrapped OptionalsStructuresClassesPropertiesComputed PropertiesProperty ObserversMethodsInitializationDeinitializationInheritancePolymorphismEncapsulationAccess ControlStatic vs Class MethodsProtocolsProtocol ExtensionsProtocol CompositionAssociated TypesExtensionsGenericsGeneric ConstraintsOpaque TypesExistential TypesType CastingAny and AnyObjectNested TypesSubscriptsKeyPathsThrowing FunctionsDo Try CatchCustom ErrorsResult TypeARCStrong ReferencesWeak ReferencesUnowned References
Swiftyn Logo

Swiftyn

The go-to platform for Apple developers. Swift, SwiftUI, and beyond.

Questions? Email us at support@swe180.com

Categories

  • SwiftUI
  • Swift Language
  • Xcode
  • visionOS

Our Products

  • SWE180
  • One Percent Engineer

Resources

  • About
  • RSS Feed
  • Apple Developer

© 2026 Swiftyn. All rights reserved.

Privacy PolicyTerms of Service

Swiftyn is the premier learning platform and developer resource for mastering the Apple ecosystem. Whether you are an aspiring iOS developer looking to learn Swift 6, a macOS engineer diving into advanced system architecture, or an XR pioneer building the future with visionOS, our beautifully crafted tutorials, roadmaps, and interview prep guides have you covered. Built by Apple developers, for Apple developers.

Swift Language8 min read

Mastering Custom Errors in Swift for Robust iOS Apps

Swift's robust error handling mechanisms are essential for building reliable iOS applications. This article guides you through defining, throwing, and catching custom errors, transforming potential runtime crashes into gracefully handled scenarios. You'll learn how to create expressive error types that precisely communicate issues within your app's logic.

The Foundation: Understanding Swift Error Handling

Error handling in Swift is a process of responding to error conditions when your program attempts to execute code that might fail. Unlike many other languages that might use exceptions, Swift uses an approach called 'recoverable errors.' This means that your code must explicitly indicate functions that can throw errors and then handle those errors when they occur.

At its core, error handling revolves around four keywords: do, try, catch, and throw. A function that can throw an error is marked with the throws keyword. When calling such a function, you use try to indicate that an error might be thrown. The do-catch statement then allows you to handle any errors that are thrown.

Custom errors become crucial when built-in error types (like NSError from Foundation) don't precisely describe the specific failure conditions of your application logic. By defining your own error types, you gain clarity, specificity, and better control over how your app reacts to different problems. This leads to more maintainable and easier-to-debug code.

swift
enum MySimpleError: Error {
    case operationFailed
}

func performRiskyOperation() throws {
    print("Attempting risky operation...")
    // Simulate a failure condition
    throw MySimpleError.operationFailed
}

do {
    try performRiskyOperation()
} catch MySimpleError.operationFailed {
    print("Caught a simple error: Operation Failed!")
} catch {
    print("Caught an unexpected error: \(error)")
}

Defining Your Own Custom Error Types: Error Protocol

In Swift, any type that conforms to the Error protocol can be used as an error. The Error protocol itself is a relatively empty marker protocol, requiring no specific methods or properties. This flexibility allows you to use various types to represent errors, most commonly enums or structs.

Using enums for Errors: Enums are the most common and recommended way to define custom errors in Swift. They allow you to define a finite set of distinct error cases, often with associated values that provide more context about the error. This makes pattern matching in catch blocks very straightforward.

Using structs or classes for Errors: While less common for simple error conditions, you can also use structs or classes. This is useful when you need to provide richer error context, perhaps including multiple properties or a specific custom description. Just ensure they conform to the Error protocol.

For enums and structs, you can optionally conform to LocalizedError and CustomStringConvertible to provide user-facing descriptions and better debugging information. This greatly enhances the developer experience and allows for more user-friendly error messages.

swift
import Foundation // Required for LocalizedError

enum NetworkError: Error, LocalizedError, CustomStringConvertible {
    case invalidURL(String)
    case noConnection
    case serverError(statusCode: Int)
    case decodingFailed(Error)
    case unknown(Error?)

    var errorDescription: String? {
        switch self {
        case .invalidURL(let urlString):
            return "The provided URL is invalid: \(urlString)"
        case .noConnection:
            return "No internet connection. Please check your network settings."
        case .serverError(let statusCode):
            return "Server experienced an issue (status code: \(statusCode)). Please try again later."
        case .decodingFailed(let underlyingError):
            return "Failed to decode data. Underlying error: \(underlyingError.localizedDescription)"
        case .unknown(let underlyingError):
            let base = "An unknown error occurred."
            return underlyingError != nil ? base + " Details: \(underlyingError!.localizedDescription)" : base
        }
    }

    var description: String {
        switch self {
        case .invalidURL(let urlString):
            return "NetworkError.invalidURL(urlString: \"\(urlString)\")"
        case .noConnection:
            return "NetworkError.noConnection"
        case .serverError(let statusCode):
            return "NetworkError.serverError(statusCode: \(statusCode))"
        case .decodingFailed(let underlyingError):
            return "NetworkError.decodingFailed(underlyingError: \(underlyingError))"
        case .unknown(let underlyingError):
            return "NetworkError.unknown(underlyingError: \(String(describing: underlyingError)))"
        }
    }
}

Throwing and Catching Specific Errors

Once you've defined your custom error types, the next step is to integrate them into your functions and methods. This involves marking a function with throws if it has the potential to fail and then using the throw keyword to signal an error when one occurs. On the consumer side, you'll use do-catch blocks to gracefully handle these specific error cases.

Swift's catch blocks allow for powerful pattern matching, similar to switch statements. This means you can catch specific error cases, including those with associated values, and deal with them precisely. You can also have a generic catch block that handles any error not specifically caught by previous blocks. This provides a safety net for unexpected errors.

Remember to handle errors as close to their source as possible while still making sense within your application's architecture. Passing errors up the call stack too far can lead to generic, unhelpful error messages or even crashes if not handled appropriately. Think about what your user needs to know or what the app needs to do to recover.

swift
func fetchData(from urlString: String) throws -> Data {
    guard let url = URL(string: urlString) else {
        throw NetworkError.invalidURL(urlString)
    }

    // Simulate network request
    guard urlString != "https://faulty.server/data" else {
        throw NetworkError.serverError(statusCode: 500)
    }

    guard urlString != "https://no.net/data" else {
        throw NetworkError.noConnection
    }
    
    print("Successfully fetched data from \(urlString)")
    return Data()
}

func processData(data: Data) throws -> String {
    // Simulate decoding failure
    let shouldFailDecoding = true // Change to false to succeed
    if shouldFailDecoding {
        struct MyDecodingError: Error {}
        throw NetworkError.decodingFailed(MyDecodingError())
    }
    return "Processed data: \(data.count) bytes"
}

func performFullOperation() {
    let validURL = "https://api.example.com/data"
    let invalidURL = "not-a-url"
    let serverErrorURL = "https://faulty.server/data"
    let noConnectionURL = "https://no.net/data"

    for url in [validURL, invalidURL, serverErrorURL, noConnectionURL] {
        print("\n--- Attempting operation with URL: \(url) ---")
        do {
            let data = try fetchData(from: url)
            let result = try processData(data: data)
            print(result)
        } catch NetworkError.invalidURL(let badURL) {
            print("Error: The provided URL '\(badURL)' is malformed. Please verify.")
        } catch NetworkError.serverError(let statusCode) {
            print("Error: Server responded with status code \(statusCode). Please check logs.")
        } catch NetworkError.noConnection {
            print("Error: No internet connection. Please check your network settings and try again.")
        } catch NetworkError.decodingFailed(let underlyingError) {
            print("Error: Failed to process data from server. Details: \(underlyingError.localizedDescription)")
        } catch let error as NetworkError {
            // Catch any other NetworkError cases not explicitly handled above
            print("A different network error occurred: \(error.localizedDescription)")
        } catch {
            // Generic catch for any other unexpected errors
            print("An unforeseen error occurred: \(error.localizedDescription)")
        }
    }
}

Handling Errors in Asynchronous Swift (async/await)

With the advent of Swift Concurrency (async/await), error handling has become even more integrated and intuitive. Functions marked with async throws can asynchronously perform work that might throw an error. The await keyword is used when calling such a function, and do-catch blocks are still used to handle errors, much like synchronous code.

This integration simplifies asynchronous error handling significantly, eliminating the need for completion handlers with Result types in many scenarios. When an async throws function is called, the execution flow is suspended until the awaited task completes. If the task throws an error, that error is propagated directly to the do-catch block surrounding the await call.

Remember that tasks in Task groups or unstructured tasks will propagate their errors to their parent task or to where they are awaited. If a child task throws an error, and the parent task awaits it, the parent task will re-throw that error. For unstructured tasks, you would typically await their value property, which can then throw. This consistent model makes error propagation across concurrent operations much cleaner and more predictable.

swift
enum DataProcessingError: Error {
    case emptyData
    case invalidFormat
    case databaseUnavailable
}

actor DataStore {
    private var cache: [String: String] = [: ]

    func save(_ value: String, for key: String) async throws {
        guard !value.isEmpty else {
            throw DataProcessingError.emptyData
        }
        // Simulate a database operation that might fail
        if key == "fail_save" {
            throw DataProcessingError.databaseUnavailable
        }
        cache[key] = value
        print("Saved '\(value)' for key '\(key)' to cache.")
    }

    func fetch(for key: String) async throws -> String {
        // Simulate async work
        try await Task.sleep(nanoseconds: 50_000_000) // 50ms delay
        guard let value = cache[key] else {
            throw DataProcessingError.invalidFormat
        }
        return value
    }
}

func performConcurrentDataOperations() async {
    let store = DataStore()

    await withTaskGroup(of: Void.self) { group in
        group.addTask { // Task 1: Successful save and fetch
            do {
                try await store.save("Hello Swift", for: "greeting")
                let greeting = try await store.fetch(for: "greeting")
                print("Fetched: \(greeting)")
            } catch DataProcessingError.emptyData {
                print("Error: Tried to save empty data.")
            } catch DataProcessingError.databaseUnavailable {
                print("Error: Database is not available for save.")
            } catch {
                print("Unexpected error in Task 1: \(error.localizedDescription)")
            }
        }

        group.addTask { // Task 2: Attempt to save empty data
            do {
                try await store.save("", for: "empty_value")
                _ = try await store.fetch(for: "empty_value")
            } catch DataProcessingError.emptyData {
                print("Successfully caught emptyData error in Task 2.")
            } catch {
                print("Unexpected error in Task 2: \(error.localizedDescription)")
            }
        }

        group.addTask { // Task 3: Attempt to save when database is unavailable
            do {
                try await store.save("Critical Data", for: "fail_save")
            } catch DataProcessingError.databaseUnavailable {
                print("Successfully caught databaseUnavailable error in Task 3.")
            } catch {
                print("Unexpected error in Task 3: \(error.localizedDescription)")
            }
        }

        group.addTask { // Task 4: Fetch a non-existent key
            do {
                let data = try await store.fetch(for: "non_existent")
                print("Fetched non-existent: \(data)")
            } catch DataProcessingError.invalidFormat {
                print("Successfully caught invalidFormat error for non_existent key in Task 4.")
            } catch {
                print("Unexpected error in Task 4: \(error.localizedDescription)")
            }
        }
    }
}

// To run this in a playground or application entry point:
// await performConcurrentDataOperations()

Best Practices for Custom Error Handling

Effective error handling goes beyond just defining error types; it involves strategic implementation to ensure your application remains robust and user-friendly. Here are some key best practices:

  • Be Specific: Your custom errors should clearly and precisely describe what went wrong. Avoid generic 'failure' errors whenever possible. The more specific your error is, the easier it is to debug and handle appropriately.

  • Provide Context with Associated Values: Whenever an error occurs, include as much relevant information as possible as associated values. This could be an Int status code, a String message, or an Error from an underlying framework. This context is invaluable for debugging and for crafting informative messages to the user.

  • Use enums for Exhaustive Cases: enums are ideal for error types because they naturally represent a finite set of possibilities. This also helps the compiler ensure you've handled all potential error cases in catch blocks.

  • Conform to LocalizedError and CustomStringConvertible: Providing errorDescription and description properties makes your errors more readable for both developers and users. errorDescription is ideal for user-facing messages, while description is excellent for logging and debugging.

  • Handle Errors as Low as Possible, Recover as High as Necessary: Catch errors where you have enough context to either resolve the issue programmatically or present a meaningful recovery option to the user. Don't re-throw an error indefinitely if you can address it at a lower level.

  • Don't Use Errors for Expected Control Flow: Errors should represent exceptional conditions, not expected programmatic outcomes (e.g., waiting for user input, or an empty list of items). For expected conditions, use optionals, Result types, or boolean flags.

  • Integrate with Logging and Analytics: When an error occurs, especially a critical one, log it with sufficient detail. Integrate with analytics tools to track error rates and understand where your application is failing in the wild. This proactive approach helps in identifying and fixing issues before they impact many users.

iOS/macOS Specifics:

  • User Alerts: For user-facing errors, present clear and actionable alerts. Avoid cryptic messages. Utilize errorDescription from LocalizedError.
  • Haptic Feedback: Consider using haptic feedback (on iOS) to subtly reinforce error conditions, guiding the user without overwhelming them.
  • Recovery Options: Always offer a recovery path if possible (e.g., 'Try Again', 'Contact Support').

By following these practices, you'll create applications that not only function correctly but also handle unexpected situations gracefully, providing a superior user experience and making your codebase more robust and maintainable.

Generic Error Handling

Becoming a stronger iOS Engineer

THE MYTH or PROBLEM: Generic Error Handling

Many developers default to broad, generic error handling (`catch { print("Error!") }`) or use `NSError` without specific error domains. This leads to untraceable bugs, poor user experience (generic 'Something went wrong.' messages), and difficulty in debugging and recovery.

swift
func loadData() throws -> Data {
    // ... complex logic ...
    throw NSError(domain: "MyApp", code: 1, userInfo: nil)
}

do {
    let data = try loadData()
    // ...
} catch {
    print("An error occurred: \(error.localizedDescription)")
    // How to recover? What specifically went wrong?
}

WHAT HAPPENS INTERNALLY? Error Propagation

When an error is thrown, Swift walks up the call stack, looking for the nearest `do-catch` block that can handle the specific error type. If no appropriate `catch` is found, or if the `try` operation wasn't within a `do` block, the program terminates.

App Entry Point
Function A (`throws`)
Function B (`throws`)
Function C (`throws`)
`throw MyCustomError.failure`
1

1. `throw` keyword

Signals an exceptional condition, stopping normal function execution.

2

2. Stack Unwinding

Swift searches the call stack for a `do-catch` statement.

3

3. `catch` Block Match

The first `catch` block whose pattern matches the thrown error is executed.

4

4. Execution Resumes

Normal program execution continues after the `do-catch` block.

5

5. No Match/Uncaught Error

If no `catch` block matches, or `try` is not in `do`, the program crashes.

Visualized execution hierarchy.

Powerful Guarantees

Type Safety

Custom errors are Swift types, ensuring compiler checks and type-safe handling.

Clarity & Specificity

Enums with associated values provide precise error context.

Graceful Recovery

`do-catch` allows targeted responses for different failure types.

REAL PRODUCTION EXAMPLE: User Authentication

A common problem in production is handling user authentication failures. A generic error message (e.g., 'Login failed') provides no useful feedback. Specific error types allow the app to guide the user to a solution.

Impact / Results
Improved user experience with actionable error messages.
Reduced support tickets due to clear problem identification.
Easier debugging for developers.
THE FIX or SOLUTION: Custom Authentication Errors
swift
enum AuthError: Error, LocalizedError {
    case invalidCredentials
    case userNotFound
    case accountLocked(reason: String)
    case networkIssue(Error)
    case unknown(Error?)

    var errorDescription: String? {
        switch self {
        case .invalidCredentials:
            return "Incorrect email or password. Please try again."
        case .userNotFound:
            return "No account found with this email."
        case .accountLocked(let reason):
            return "Your account is locked: \(reason). Please contact support."
        case .networkIssue(let underlyingError):
            return "Network problem: \(underlyingError.localizedDescription). Please check your connection."
        case .unknown:
            return "An unexpected authentication error occurred. Please try again."
        }
    }
}

func login(email: String, password: String) async throws {
    if email == "fail@example.com" {
        throw AuthError.invalidCredentials
    } else if email == "locked@example.com" {
        throw AuthError.accountLocked(reason: "Too many failed attempts")
    } else if email == "no@connection.com" {
         enum MockNetworkError: Error { case connectionLost } 
         throw AuthError.networkIssue(MockNetworkError.connectionLost)
    }
    print("User \(email) logged in successfully.")
}

// In a SwiftUI View or ViewModel:
func handleLoginButtonTap(email: String, password: String) async {
    do {
        try await login(email: email, password: password)
        print("Login successful, navigate to home screen.")
    } catch let authError as AuthError {
        // Display authError.errorDescription to the user
        print("User-facing alert: \(authError.localizedDescription)")
    } catch {
        print("Generic login error: \(error.localizedDescription)")
        // Display a generic 'Something went wrong' alert.
    }
}

INTERVIEW PERSPECTIVE

Common Question

“Describe Swift's error handling and how you would implement custom errors in a network layer.”

Strong Answer

A strong answer demonstrates understanding of `do-catch-throw`, the `Error` protocol, and the benefits of `enum`s for errors with associated values. It should also mention `LocalizedError` for user-facing messages and `async throws` for concurrency. For a network layer, candidates should propose a `NetworkError` enum (e.g., `.invalidURL`, `.serverError(statusCode: Int)`, `.noConnection`, `.decodingFailed(Error)`) to provide specific context for each failure point.

Interviewers Expect you to understand:
  • Knowledge of `Error` protocol
  • Use of `enum` with associated values
  • `LocalizedError` for UI
  • `async throws` integration
  • Specific examples of custom network errors
KEY TAKEAWAY

Define specific custom error enums with associated values to clearly describe failure states. Conform to `LocalizedError` for user-friendly messages and judiciously use `do-catch` or `try?`/`try!` for robust, maintainable error handling throughout your Swift applications.

Common Interview Questions

When should I use `Result` type instead of throwing errors?

The `Result` type (`Result<Success, Failure>`) is typically preferred for `async` functions or APIs that return immediately but finish later, especially in scenarios where an error is an *expected* outcome of an operation (e.g., fetching data that might legitimately not exist). Throwing errors is better for *exceptional* conditions that disrupt the normal flow of execution and signal that something unexpected went wrong.

Can I use `try!` or `try?` for custom errors?

Yes, `try!` (force unwrap) and `try?` (optional try) work with any error-throwing function, including those that throw custom errors. `try!` should only be used when you are absolutely certain that a function will not throw an error, as it will crash your application if an error occurs. `try?` converts a throwing function's result into an optional, returning `nil` if an error is thrown, effectively suppressing the error. Use `try?` cautiously, ensuring that `nil` is a valid and handled outcome in your logic.

How can I provide more localized error messages to users?

To provide localized error messages, make your custom error type conform to the `LocalizedError` protocol. This protocol requires an `errorDescription` property (a `String?`). Within this computed property, you can use `NSLocalizedString` or `String(localized:key)` to return localized strings based on the specific error case and its associated values. The system will then automatically pick the correct localized description if you use `error.localizedDescription`.

What's the difference between `Error` and `LocalizedError`?

The `Error` protocol is the fundamental marker protocol in Swift that indicates a type can be used as an error. It has no requirements. `LocalizedError` is a specialized protocol that extends `Error` by adding properties like `errorDescription`, `failureReason`, `recoverySuggestion`, and `helpAnchor`. Conforming to `LocalizedError` allows you to provide user-facing localized text for your errors, which can then be displayed via `error.localizedDescription`.

Is it possible to mix custom errors with Cocoa `NSError`?

Yes, Swift's error handling interoperates seamlessly with `NSError`. When a non-`Error` Objective-C method is imported into Swift with an `NSError **` parameter, it's typically imported as a `throws` method. Similarly, you can catch `NSError` instances in your `catch` blocks or convert them to your custom error types if needed. You can also re-throw `NSError` instances. This allows for smooth integration with existing Apple frameworks.

#Swift#Error Handling#Custom Errors#Reliability#iOS Development#SwiftUI