From 344ae2f6ba0ddd9496401af4e0b4388436b955d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Fri, 29 Nov 2024 23:57:40 +0100 Subject: [PATCH 1/4] Add more intuitive Throwable enum replacing Error and LocalizedError --- README.md | 99 ++++++++++++++++++++++++++++++++ Sources/ErrorKit/Throwable.swift | 83 ++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 README.md create mode 100644 Sources/ErrorKit/Throwable.swift diff --git a/README.md b/README.md new file mode 100644 index 0000000..1028eac --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# ErrorKit + +ErrorKit makes error handling in Swift more intuitive. It reduces boilerplate while providing more insights. Helpful for users, fun for developers! + +TODO: list all the advantages when using ErrorKit over Swift's native types + +## Why we are introducing the `Throwable` protocol to replace `Error` + +### Confusing `Error` API + +The `Error` type in Swift is a very simple protocol that has no requirements. But it has exactly one computed property we can call named `localizedDescription` which returns a text we can log or show users. + +You might have written something like this, providing a `localizedDescription`: + +```swift +enum NetworkError: Error, CaseIterable { + case noConnectionToServer + case parsingFailed + + var localizedDescription: String { + switch self { + case .noConnectionToServer: + return "No Connection to Server Established" + + case .parsingFailed: + return "Parsing Failed" + } + } +} +``` + +But actually, this doesn't work. If we randomly throw an error and print it's `localizedDescription` like in this view: + +```swift +struct ContentView: View { + var body: some View { + Button("Throw Random NetworkError") { + do { + throw NetworkError.allCases.randomElement()! + } catch { + print("Caught error with message: \(error.localizedDescription)") + } + } + } +} +``` + +The console output is cryptic and not at all what we would expect: 😱 + +```bash +Caught error with message: The operation couldn’t be completed. (ErrorKitDemo.NetworkError error 0.) +``` + +There is zero info about what error case was thrown – heck, not even the enum case name was provided, let alone our provided message! Why is that? That's because the Swift `Error` type is actually bridged to `NSError` which works entirely differently (with `domain`, `code`, and `userInfo`). + +The correct way in Swift to provide your own error type is actually to conform to `LocalizedError`! It has the following requirements: `errorDescription: String?`, `failureReason: String?`, `recoverySuggestion: String?`, and `helpAnchor: String?`. + +But all of these are optional, so you won't get any build errors when writing your own error types, making it easy to forget providing a user-friendly message. And the only field that is being used for `localizedDescription` actually is `errorDescription`, the failure reason or recovery suggestions, for example, get completely ignored. And the help anchor is a legacy leftover from old macOS error dialogs, it's very uncommon nowadays. + +All this makes `LocalizedError` confusing and unsafe to use. Which is why we provide our own protocol: + +```swift +public protocol Throwable: LocalizedError { + var localizedDescription: String { get } +} +``` + +It is super simple and clear. We named it `Throwable` which is consistent with the `throw` keyword and has the common `able` ending used for protocols in Swift (like `Codable`, `Identifiable` and many others). And it actually requires a field and that field is named exactly like the `localizedDescription` we call when catching errors in Swift, making errors intuitive to write. + +With this we can simply write: + +```swift +enum NetworkError: Throwable, CaseIterable { + case noConnectionToServer + case parsingFailed + + var localizedDescription: String { + switch self { + case .noConnectionToServer: "Unable to connect to the server." + case .parsingFailed: "Data parsing failed." + } + } +} +``` + +Now when printing `error.localizedDescription` we get exactly the message we expect! 🥳 + +But it doesn't end there. We know that not all apps are localized, and not all developer have the time to localize all their errors right away. So we even provide a shorter version that you can use in your first iteration if your cases have no parameters. Just provide raw values like so, making your error type definition even shorter while maintaining descriptive error message: + +```swift +enum NetworkError: String, Throwable, CaseIterable { + case noConnectionToServer = "Unable to connect to the server." + case parsingFailed = "Data parsing failed." +} +``` + +Section summary: + +> We recommend conforming all your custom error types to `Throwable` rather than `Error` or `LocalizedError`. It has one requirement, `localizedDescription: String`, which will be exactly what you expect it to be. diff --git a/Sources/ErrorKit/Throwable.swift b/Sources/ErrorKit/Throwable.swift new file mode 100644 index 0000000..841fdc0 --- /dev/null +++ b/Sources/ErrorKit/Throwable.swift @@ -0,0 +1,83 @@ +import Foundation + +/// A protocol that makes error handling in Swift more intuitive by requiring a user-friendly `localizedDescription` property. +/// +/// `Throwable` extends `LocalizedError` and simplifies the process of defining error messages, +/// ensuring that developers can provide meaningful feedback for errors without the confusion associated with Swift's native `Error` and `LocalizedError` types. +/// +/// ### Key Features: +/// - Requires a `localizedDescription`, making it easier to provide custom error messages. +/// - Offers a default implementation for `errorDescription`, ensuring smooth integration with `LocalizedError`. +/// - Supports `RawRepresentable` enums with `String` as `RawValue` to minimize boilerplate. +/// +/// ### Why Use `Throwable`? +/// - **Simplified API**: Unlike `LocalizedError`, `Throwable` focuses on a single requirement: `localizedDescription`. +/// - **Intuitive Naming**: The name aligns with Swift's `throw` keyword and other common `-able` protocols like `Codable`. +/// - **Readable Error Handling**: Provides concise, human-readable error descriptions. +/// +/// ### Usage Example: +/// +/// #### 1. Custom Error with Manual `localizedDescription`: +/// ```swift +/// enum NetworkError: Throwable { +/// case noConnectionToServer +/// case parsingFailed +/// +/// var localizedDescription: String { +/// switch self { +/// case .noConnectionToServer: "Unable to connect to the server." +/// case .parsingFailed: "Data parsing failed." +/// } +/// } +/// } +/// ``` +/// +/// #### 2. Custom Error Using `RawRepresentable` for Minimal Boilerplate: +/// ```swift +/// enum NetworkError: String, Throwable { +/// case noConnectionToServer = "Unable to connect to the server." +/// case parsingFailed = "Data parsing failed." +/// } +/// ``` +/// +/// #### 3. Throwing and Catching Errors: +/// ```swift +/// struct ContentView: View { +/// var body: some View { +/// Button("Throw Random NetworkError") { +/// do { +/// throw NetworkError.allCases.randomElement()! +/// } catch { +/// print("Caught error with message: \(error.localizedDescription)") +/// } +/// } +/// } +/// } +/// ``` +/// Output: +/// ``` +/// Caught error with message: Unable to connect to the server. +/// ``` +/// +public protocol Throwable: LocalizedError { + /// A human-readable error message describing the error. + var localizedDescription: String { get } +} + +// MARK: - Default Implementations + +/// Provides a default implementation for `Throwable` when the conforming type is a `RawRepresentable` with a `String` raw value. +/// +/// This allows enums with `String` raw values to automatically use the raw value as the error's `localizedDescription`. +extension Throwable where Self: RawRepresentable, RawValue == String { + public var localizedDescription: String { + self.rawValue + } +} + +/// Provides a default implementation for `errorDescription` required by `LocalizedError`, ensuring it returns the value of `localizedDescription`. +extension Throwable { + public var errorDescription: String? { + self.localizedDescription + } +} From 4cfbcffed40fa5d560ab3070e7c1c8da81592486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Sat, 30 Nov 2024 00:01:02 +0100 Subject: [PATCH 2/4] Add GitHub Actions CI to ensure builds & tests succeed on Mac & Linux --- .github/workflows/main.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..5885ef5 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,23 @@ +name: CI + +on: + pull_request: + +jobs: + test-linux: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Run tests + run: swift test + + test-macos: + runs-on: macos-15 + + steps: + - uses: actions/checkout@v4 + + - name: Run tests + run: swift test From 49be80d9c88b2941107fa2c73205a4b4963a82ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Sat, 30 Nov 2024 00:10:31 +0100 Subject: [PATCH 3/4] Improve the phrasing of the throwable README section --- README.md | 60 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 1028eac..b856298 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ # ErrorKit -ErrorKit makes error handling in Swift more intuitive. It reduces boilerplate while providing more insights. Helpful for users, fun for developers! +**ErrorKit** makes error handling in Swift more intuitive. It reduces boilerplate code while providing clearer insights. Helpful for users, fun for developers! -TODO: list all the advantages when using ErrorKit over Swift's native types +*TODO: Add a list of advantages of using ErrorKit over Swift’s native error handling types.* -## Why we are introducing the `Throwable` protocol to replace `Error` +--- -### Confusing `Error` API +## Why We Introduced the `Throwable` Protocol to Replace `Error` -The `Error` type in Swift is a very simple protocol that has no requirements. But it has exactly one computed property we can call named `localizedDescription` which returns a text we can log or show users. +### The Confusing `Error` API -You might have written something like this, providing a `localizedDescription`: +Swift's `Error` protocol is simple – too simple. It has no requirements, but it offers one computed property, `localizedDescription`, which is often used to log errors or display messages to users. + +Consider the following example where we provide a `localizedDescription` for an enum: ```swift enum NetworkError: Error, CaseIterable { @@ -19,17 +21,14 @@ enum NetworkError: Error, CaseIterable { var localizedDescription: String { switch self { - case .noConnectionToServer: - return "No Connection to Server Established" - - case .parsingFailed: - return "Parsing Failed" + case .noConnectionToServer: "No connection to the server." + case .parsingFailed: "Data parsing failed." } } } ``` -But actually, this doesn't work. If we randomly throw an error and print it's `localizedDescription` like in this view: +You might expect this to work seamlessly, but it doesn’t. If we randomly throw an error and print its `localizedDescription`, like in the following SwiftUI view: ```swift struct ContentView: View { @@ -45,19 +44,30 @@ struct ContentView: View { } ``` -The console output is cryptic and not at all what we would expect: 😱 +The console output will surprise you: 😱 ```bash Caught error with message: The operation couldn’t be completed. (ErrorKitDemo.NetworkError error 0.) ``` -There is zero info about what error case was thrown – heck, not even the enum case name was provided, let alone our provided message! Why is that? That's because the Swift `Error` type is actually bridged to `NSError` which works entirely differently (with `domain`, `code`, and `userInfo`). +There’s no information about the specific error case. Not even the enum case name appears, let alone the custom message! Why? Because Swift’s `Error` protocol is bridged to `NSError`, which uses `domain`, `code`, and `userInfo` instead. + +### The "Correct" Way: `LocalizedError` + +The correct approach is to conform to `LocalizedError`, which defines the following optional properties: -The correct way in Swift to provide your own error type is actually to conform to `LocalizedError`! It has the following requirements: `errorDescription: String?`, `failureReason: String?`, `recoverySuggestion: String?`, and `helpAnchor: String?`. +- `errorDescription: String?` +- `failureReason: String?` +- `recoverySuggestion: String?` +- `helpAnchor: String?` -But all of these are optional, so you won't get any build errors when writing your own error types, making it easy to forget providing a user-friendly message. And the only field that is being used for `localizedDescription` actually is `errorDescription`, the failure reason or recovery suggestions, for example, get completely ignored. And the help anchor is a legacy leftover from old macOS error dialogs, it's very uncommon nowadays. +However, since all of these properties are optional, you won’t get any compiler errors if you forget to implement them. Worse, only `errorDescription` affects `localizedDescription`. Fields like `failureReason` and `recoverySuggestion` are ignored, while `helpAnchor` is rarely used today. -All this makes `LocalizedError` confusing and unsafe to use. Which is why we provide our own protocol: +This makes `LocalizedError` both confusing and error-prone. + +### The Solution: `Throwable` + +To address these issues, **ErrorKit** introduces the `Throwable` protocol: ```swift public protocol Throwable: LocalizedError { @@ -65,9 +75,9 @@ public protocol Throwable: LocalizedError { } ``` -It is super simple and clear. We named it `Throwable` which is consistent with the `throw` keyword and has the common `able` ending used for protocols in Swift (like `Codable`, `Identifiable` and many others). And it actually requires a field and that field is named exactly like the `localizedDescription` we call when catching errors in Swift, making errors intuitive to write. +This protocol is simple and clear. It’s named `Throwable` to align with Swift’s `throw` keyword and follows Swift’s convention of using the `able` suffix (like `Codable` and `Identifiable`). Most importantly, it requires the `localizedDescription` property, ensuring your errors behave exactly as expected. -With this we can simply write: +Here’s how you use it: ```swift enum NetworkError: Throwable, CaseIterable { @@ -83,9 +93,11 @@ enum NetworkError: Throwable, CaseIterable { } ``` -Now when printing `error.localizedDescription` we get exactly the message we expect! 🥳 +When you print `error.localizedDescription`, you'll get exactly the message you expect! 🥳 -But it doesn't end there. We know that not all apps are localized, and not all developer have the time to localize all their errors right away. So we even provide a shorter version that you can use in your first iteration if your cases have no parameters. Just provide raw values like so, making your error type definition even shorter while maintaining descriptive error message: +### Even Shorter Error Definitions + +Not all apps are localized, and developers may not have time to provide localized descriptions immediately. To make error handling even simpler, `Throwable` allows you to define your error messages using raw values: ```swift enum NetworkError: String, Throwable, CaseIterable { @@ -94,6 +106,8 @@ enum NetworkError: String, Throwable, CaseIterable { } ``` -Section summary: +This approach eliminates boilerplate code while keeping the error definitions concise and descriptive. + +### Summary -> We recommend conforming all your custom error types to `Throwable` rather than `Error` or `LocalizedError`. It has one requirement, `localizedDescription: String`, which will be exactly what you expect it to be. +> Conform your custom error types to `Throwable` instead of `Error` or `LocalizedError`. The `Throwable` protocol requires only `localizedDescription: String`, ensuring your error messages are exactly what you expect – no surprises. From 5344fa4a58a4b580a80565c42b663ce335cf79a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Sat, 30 Nov 2024 00:12:53 +0100 Subject: [PATCH 4/4] Remove CaseIterable conformance from example enum error types --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b856298..3b4ad1b 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ This protocol is simple and clear. It’s named `Throwable` to align with Swift Here’s how you use it: ```swift -enum NetworkError: Throwable, CaseIterable { +enum NetworkError: Throwable { case noConnectionToServer case parsingFailed @@ -100,7 +100,7 @@ When you print `error.localizedDescription`, you'll get exactly the message you Not all apps are localized, and developers may not have time to provide localized descriptions immediately. To make error handling even simpler, `Throwable` allows you to define your error messages using raw values: ```swift -enum NetworkError: String, Throwable, CaseIterable { +enum NetworkError: String, Throwable { case noConnectionToServer = "Unable to connect to the server." case parsingFailed = "Data parsing failed." }