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.

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
affectslocalizedDescription
; 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 issuesFileError
for file system operationsDatabaseError
for data persistence issuesValidationError
for input validationPermissionError
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:
- Clarity for new developers:
The protocol clearly indicates how to define error messages - Compile-time safety:
The non-optional requirement ensures all cases have messages - Localization support:
Works perfectly withString(localized:)
for internationalization - Reduced boilerplate:
Especially with raw string values and built-in types - Improved user experience:
Clear error messages help users understand what went wrong - 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 throws
, do
/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:
Have you encountered this error message confusion in your Swift development? How have you addressed it? Let me know on social media (links below)!
A simple & fast AI-based translator for String Catalogs & more.
Get it now and localize your app to over 100 languages in minutes!