Swift provides a powerful mechanism for defining our own custom errors, allowing for better error handling and creating more informative and user-friendly errors in our applications. This capability is crucial when we aim to enhance the robustness and usability of Swift-based projects. By crafting proper custom errors, we can guide users with clarity and precision, significantly improving the overall user experience.
In this post, we’ll dive into the essentials of defining custom errors in Swift, exploring particular aspects and meeting some not so well-known, yet quite valuable APIs. Through practical examples and detailed explanations, you’ll get the knowledge that will let you write more reliable, as well as more intuitive for your users software.
Creating a custom error type
Defining custom errors requires the implementation of a custom type, and more particularly of an enumeration. This must mandatorily conform to the Error
protocol for an important reason; without that conformance the cases defined in the enum won’t be catchable in a do-catch
statement, which is the ultimate goal when specifying custom errors.
Let’s see an example of a hypothetical scenario where we implement a custom type containing errors regarding user-provided passwords:
1 2 3 4 5 6 7 8 9 10 11 |
enum PasswordError: Error { case tooShort(minLength: Int) case tooLong(maxLength: Int) case noUppercaseCharacter case noLowercaseCharacter case noNumber case noSpecialCharacter case easilyGuessed } |
The PasswordError
enum demonstrated above has a mix of cases both with and without associated values. Each one represents a different kind of error that may possibly occur while a user tries to enter a password to a form. Enums with associated values hold additional information regarding the error.
The above example is enough in order to define custom errors. However, it’s only sufficient if we do not wish to also provide any kind of error descriptions. That’s a situation perfectly acceptable, as we may define errors meant to be consumed internally and textual descriptions never end up to the user, or us, to developers. If, on the other hand, error descriptions are desirable, then we have a few options to choose from in order to specify them.
Specifying basic error descriptions
The most common and usual approach to define descriptions for our custom errors is by conforming to CustomStringConvertible
protocol and implementing the required description
computed property. Usually, and mostly for clarity reasons, that conformance takes place to an extension of the enum that contains the custom error cases.
For our PasswordError
example, we can implement the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
extension PasswordError: CustomStringConvertible { var description: String { switch self { case .tooShort: return “Password is too short” case .tooLong: return “Password is too long” case .noUppercaseCharacter: return “Password lacks uppercase letter” case .noLowercaseCharacter: return “Password lacks lowercase letter” case .noNumber: return “Password lacks number” case .noSpecialCharacter: return “Password lacks special character” case .easilyGuessed: return “Password is common” } } } |
Most of the times, an implementation similar to the one demonstrated here is fine so we can get textual descriptions of the errors. We can print them in the console while debugging and make conclusions about the various issues we encounter in the app making process.
1 2 3 4 5 |
let error = PasswordError.noUppercaseCharacter print(error.description) // Output: Password lacks uppercase letter |
However, the above is not the best choice to make when it comes to user-facing descriptions. There is a better alternative, which, as you will see next, comes with some advantages.
Providing localized error descriptions
To display user-friendly, localized messages to different languages, our custom error enum should conform to the LocalizedError
protocol. Three new computed properties become available by doing so, with the first one being the errorDescription
. That’s a String
property, where, exactly as before, we set a description for each error case. To allow localization, make sure that each text is given as argument to the String(localized:)
method.
For example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
extension PasswordError: LocalizedError { public var errorDescription: String? { switch self { case .tooShort(let minLength): return String(localized: “Password must be at least \(minLength) characters long”) case .tooLong(let maxLength): return String(localized: “Password cannot exceed \(maxLength) characters”) case .noUppercaseCharacter: return String(localized: “Password must contain at least one uppercase letter”) case .noLowercaseCharacter: return String(localized: “Password must contain at least one lowercase letter”) case .noNumber: return String(localized: “Password must contain at least one number”) case .noSpecialCharacter: return String(localized: “Password must contain at least one special character”) case .easilyGuessed: return String(localized: “Password is too common. Please choose a stronger password”) } } } |
You might correctly wonder why it’s preferable to conform to LocalizedError
and implement the errorDescription
property. The description
property of the CustomStringConvertible
protocol can lead to the same results if we just return localizable strings for each error case.
There are two good reasons on why to choose LocalizedError
. Firstly, conforming to CustomStringConvertible
usually aims to provide descriptions that we can use during the development process of an app, such as printing them in the console. Secondly, LocalizedError
comes with a couple of additional perks that allow to supply more than one error messages, and therefore to achieve the best possible user experience if shown in the frontend.
Specifying failure reason and recovery suggestions
The two additional advantages we get from LocalizedError
protocol regard two more computed properties beyond errorDescription
, each of which we can use to provide additional descriptions about our custom errors. Both of them are optional, so we can use them on demand.
The first one is the failureReason
, and that’s the place to specify more detailed explanation of an error’s cause. Similarly to errorDescription
property, it may return a string value for each error case:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
extension PasswordError: LocalizedError { public var failureReason: String? { switch self { case .tooShort: return String(localized: “The password is too short”) case .tooLong: return String(localized: “The password is too long”) case .noUppercaseCharacter: return String(localized: “The password doesn’t contain any uppercase letters”) case .noLowercaseCharacter: return String(localized: “The password doesn’t contain any lowercase letters”) case .noNumber: return String(localized: “The password doesn’t contain any numbers”) case .noSpecialCharacter: return String(localized: “The password doesn’t contain any special characters”) case .easilyGuessed: return String(localized: “The password is a commonly used password”) } } } |
The other additional computed property is the recoverySuggestion
. Its name speaks for itself, as that’s where we define descriptions about how users can fix or overcome an error:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
extension PasswordError: LocalizedError { public var recoverySuggestion: String? { switch self { case .tooShort(let minLength): return String(localized: “Make your password at least \(minLength) characters long”) case .tooLong(let maxLength): return String(localized: “Shorten your password to less than \(maxLength) characters”) case .noUppercaseCharacter: return String(localized: “Include at least one uppercase letter (A-Z)”) case .noLowercaseCharacter: return String(localized: “Include at least one lowercase letter (a-z)”) case .noNumber: return String(localized: “Include at least one number (0-9)”) case .noSpecialCharacter: return String(localized: “Include at least one special character (e.g., !@#$%^&*)”) case .easilyGuessed: return String(localized: “Choose a unique and strong password”) } } } |
By making use of all three computed properties of LocalizedError
, we are able to create user-friendly, informative and helpful custom errors that increase user experience. Just remember what each property answers to:
errorDescription
: What went wrong?failureReason
: Why?recoverySuggestion
: How to fix?
For instance:
1 2 3 4 5 6 7 8 9 10 |
let error = PasswordError.noUppercaseCharacter print(error.localizedDescription) print(error.failureReason ?? “”) print(error.recoverySuggestion ?? “”) // Output: // Password must contain at least one uppercase letter // The password doesn’t contain any uppercase letters // Include at least one uppercase letter (A-Z) |
Conclusion
Defining custom errors is a common task for developers, but as you have seen it’s a lot more than just implementing an enum. There are protocols to conform to and specify descriptions for custom errors, either basic or more advanced ones. I would suggest to use the description
property of the CustomStringConvertible
protocol in order to define error messages that you’ll use while making an app. Use the LocalizedError
computed properties for localizable messages which will be presented to users. If you were not aware of the failureReason
and recoverySuggestion
properties discussed above, now it’s a good time to start using them as well.
Thank you for reading!