Swift Error Handling Done Right: Overcoming the Objective-C Error Legacy

Tired of cryptic Swift error messages like '(YourError error 0)'? Here's how to fix them for good—with clarity and elegance.

Swift Error Handling Done Right: Overcoming the Objective-C Error Legacy

Have you ever spent time carefully crafting error messages in your Swift app, only to find they never actually appear? Instead, your users (or you during debugging) see cryptic messages like:

"The operation couldn't be completed. (YourApp.YourError error 0.)"

If so, you're not alone. This confusing behavior has been tripping up Swift developers—from beginners to experts—since the language was introduced. Today, I want to explain why this happens and present a solution that makes Swift error handling more intuitive.

The Surprising Behavior of Swift's Error Protocol

Let's look at a simple example that demonstrates the problem:

enum NetworkError: Error {
   case noConnectionToServer
   case parsingFailed

   var localizedDescription: String {
      switch self {
      case .noConnectionToServer:
         return "No connection to the server."
      case .parsingFailed:
         return "Data parsing failed."
      }
   }
}

// Using the error
do {
   throw NetworkError.noConnectionToServer
} catch {
   print("Error message: \(error.localizedDescription)")
   // Expected: "No connection to the server."
   // Actual: "The operation couldn't be completed. (AppName.NetworkError error 0.)"
}

What went wrong? We defined a clear error message, but Swift ignored it completely!

Why This Happens: The NSError Bridge

This confusing behavior occurs because Swift's Error protocol is bridged to Objective-C's NSError class behind the scenes. When you access localizedDescription, Swift doesn't use your property—it creates an NSError with a domain (your module name), a code (the enum case's integer value), and a default message.

This design might make sense for Objective-C interoperability, but it creates a terrible developer experience, especially for those new to Swift.

The "Official" Solution: LocalizedError

Swift does provide an official solution: the LocalizedError protocol. Here's how you're supposed to use it:

enum NetworkError: LocalizedError {
   case noConnectionToServer
   case parsingFailed

   var errorDescription: String? { // Note: Optional String
      switch self {
      case .noConnectionToServer:
         return "No connection to the server."
      case .parsingFailed:
         return "Data parsing failed."
      }
   }
   
   // There are also these optional properties that are rarely used
   var failureReason: String? { return nil }
   var recoverySuggestion: String? { return nil }
   var helpAnchor: String? { return nil }
}

While this works, it has several problems:

  • All properties are optional (String?), so the compiler won't help you if you forget to handle a case
  • Only errorDescription affects localizedDescription; the other properties are often ignored
  • The naming doesn't clearly indicate which property affects the displayed message
  • It still uses a legacy approach based on Cocoa error handling patterns

A Better Solution: The Throwable Protocol

After experiencing this frustration too many times, I created a simpler solution as part of ErrorKit—a protocol called Throwable:

public protocol Throwable: LocalizedError {
   var userFriendlyMessage: String { get }
}

This protocol has several advantages:

  • It has a single, non-optional requirement—no more forgetting cases
  • The name userFriendlyMessage clearly expresses intent
  • It extends LocalizedError for compatibility (no extra work for you!)
  • It follows Swift naming conventions with the -able suffix

Here's how you use it:

enum NetworkError: Throwable {
   case noConnectionToServer
   case parsingFailed

   var userFriendlyMessage: String {
      switch self {
      case .noConnectionToServer:
         return "Unable to connect to the server."
      case .parsingFailed:
         return "Data parsing failed."
      }
   }
}

// Using the error
do {
   throw NetworkError.noConnectionToServer
} catch {
   print("Error message: \(error.localizedDescription)")
   // Now correctly shows: "Unable to connect to the server." 🎉
}

With Throwable, what you see is what you get—your error messages appear exactly as intended, with no surprises.

Quick Development with String Raw Values

For rapid prototyping, Throwable also works seamlessly with string raw values:

enum NetworkError: String, Throwable {
   case noConnectionToServer = "Unable to connect to the server."
   case parsingFailed = "Data parsing failed."
}

// That's it! No extra implementation needed

The raw string values automatically become your error messages, eliminating boilerplate during early development. Later, when you're ready for proper localization, you can switch to using String(localized:) in a full implementation of userFriendlyMessage.

Ready-To-Use Error Types

To further reduce boilerplate, ErrorKit includes pre-defined error types for common scenarios:

func fetchData() async throws {
    guard isNetworkAvailable else {
        throw NetworkError.noInternet
    }
    
    guard let url = URL(string: path) else {
        throw ValidationError.invalidInput(field: "URL path")
    }
    
    // More implementation...
}

These built-in types include:

  • NetworkError for connectivity and API issues
  • FileError for file system operations
  • DatabaseError for data persistence issues
  • ValidationError for input validation
  • PermissionError for authorization issues
  • And several more...

Each built-in type already conforms to Throwable and provides localized user-friendly messages out of the box, saving you time while maintaining clarity.

Quick One-Off Errors with GenericError

For those situations where you need a custom message without defining a whole new error type, ErrorKit provides GenericError:

func quickOperation() throws {
    guard condition else {
        throw GenericError(userFriendlyMessage: "The operation couldn't be completed because a specific condition wasn't met.")
    }
    
    // More implementation...
}

This is perfect for early development or unique error cases that don't warrant a dedicated error type.

Benefits Beyond Better Messages

Adopting Throwable doesn't just fix error messages—it brings several additional benefits:

  1. Clarity for new developers:
    The protocol clearly indicates how to define error messages
  2. Compile-time safety:
    The non-optional requirement ensures all cases have messages
  3. Localization support:
    Works perfectly with String(localized:) for internationalization
  4. Reduced boilerplate:
    Especially with raw string values and built-in types
  5. Improved user experience:
    Clear error messages help users understand what went wrong
  6. Better debugging:
    Meaningful error messages make debugging faster

Making the Transition

The best part? Throwable is a drop-in replacement for Error:

// Before
enum AppError: Error {
    case configurationFailed
}

// After
enum AppError: Throwable {
    case configurationFailed
    
    var userFriendlyMessage: String {
        switch self {
        case .configurationFailed:
           return "Failed to load configuration."
        }
    }
}

Existing code using throwsdo/catch, and other error handling patterns work exactly the same—the only difference is that now your error messages actually appear as intended.

Conclusion

Swift's error handling is powerful, but its message handling has been a confusing pain point for too long. The Throwable protocol provides a simple, intuitive solution that aligns with Swift's design principles while fixing a longstanding issue.

By adopting Throwable for your error types, you get clearer error messages, reduced boilerplate, and a more intuitive developer experience. Combined with built-in error types and the GenericError fallback, it creates a comprehensive approach to error handling that works the way you'd expect.

If you want to try this approach in your own projects, check out ErrorKit, which includes the Throwable protocol, built-in error types, and many other error handling improvements for Swift:

GitHub - FlineDev/ErrorKit: Simplified error handling with built-in user-friendly messages for common errors. Fully localized. Community-driven.
Simplified error handling with built-in user-friendly messages for common errors. Fully localized. Community-driven. - FlineDev/ErrorKit

Have you encountered this error message confusion in your Swift development? How have you addressed it? Let me know on social media (links below)!

🌐
Wanna grow your app? Check out TranslateKit!
A simple & fast AI-based translator for String Catalogs & more.
Get it now and localize your app to over 100 languages in minutes!
👨‍💻
Want to Connect?
Follow me on 🦋 Bluesky, 🐦 Twitter (X), and 🦣 Mastodon.