Skip to content

Commit 6dc521d

Browse files
authored
Merge pull request #19 from FlineDev/wip/nested-errors
Introduce `Catching` protocol for convenient nesting of error types for typed throws
2 parents db8a7d4 + f93f005 commit 6dc521d

File tree

12 files changed

+588
-45
lines changed

12 files changed

+588
-45
lines changed

README.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,131 @@ When contributing:
293293
- Follow the existing naming conventions
294294

295295
Together, we can build a comprehensive set of error types that cover most common scenarios in Swift development and create a more unified error handling experience across the ecosystem.
296+
297+
298+
## Simplified Error Nesting with the `Catching` Protocol
299+
300+
ErrorKit's `Catching` protocol simplifies error handling in modular applications by providing an elegant way to handle nested error hierarchies. It eliminates the need for explicit wrapper cases while maintaining type safety through typed throws.
301+
302+
### The Problem with Manual Error Wrapping
303+
304+
In modular applications, errors often need to be propagated up through multiple layers. The traditional approach requires defining explicit wrapper cases for each possible error type:
305+
306+
```swift
307+
enum ProfileError: Error {
308+
case validationFailed(field: String)
309+
case databaseError(DatabaseError) // Wrapper case needed
310+
case networkError(NetworkError) // Another wrapper case
311+
case fileError(FileError) // Yet another wrapper
312+
}
313+
314+
// And manual error wrapping in code:
315+
do {
316+
try database.fetch(id)
317+
} catch let error as DatabaseError {
318+
throw .databaseError(error)
319+
}
320+
```
321+
322+
This leads to verbose error types and tedious error handling code when attempting to use typed throws.
323+
324+
### The Solution: `Catching` Protocol
325+
326+
ErrorKit's `Catching` protocol provides a single `caught` case that can wrap any error, plus a convenient `catch` function for automatic error wrapping:
327+
328+
```swift
329+
enum ProfileError: Throwable, Catching {
330+
case validationFailed(field: String)
331+
case caught(Error) // Single case handles all nested errors!
332+
}
333+
334+
struct ProfileRepository {
335+
func loadProfile(id: String) throws(ProfileError) {
336+
// Regular error throwing for validation
337+
guard id.isValidFormat else {
338+
throw ProfileError.validationFailed(field: "id")
339+
}
340+
341+
// Automatically wrap any database or file errors
342+
let userData = try ProfileError.catch {
343+
let user = try database.loadUser(id)
344+
let settings = try fileSystem.readUserSettings(user.settingsPath)
345+
return UserProfile(user: user, settings: settings)
346+
}
347+
}
348+
}
349+
```
350+
351+
Note the `ProfileError.catch` function call, which wraps any errors into the `caught` case and also passes through the return type.
352+
353+
### Built-in Support in ErrorKit Types
354+
355+
All of ErrorKit's built-in error types (`DatabaseError`, `FileError`, `NetworkError`, etc.) already conform to `Catching`, allowing you to easily wrap system errors or other error types:
356+
357+
```swift
358+
func saveUserData() throws(DatabaseError) {
359+
// Automatically wraps SQLite errors, file system errors, etc.
360+
try DatabaseError.catch {
361+
try database.beginTransaction()
362+
try database.execute(query)
363+
try database.commit()
364+
}
365+
}
366+
```
367+
368+
### Adding Catching to Your Error Types
369+
370+
Making your own error types support automatic error wrapping is simple:
371+
372+
1. Conform to the `Catching` protocol
373+
2. Add the `caught(Error)` case to your error type
374+
3. Use the `catch` function for automatic wrapping
375+
376+
```swift
377+
enum AppError: Throwable, Catching {
378+
case invalidConfiguration
379+
case caught(Error) // Required for Catching protocol
380+
381+
var userFriendlyMessage: String {
382+
switch self {
383+
case .invalidConfiguration:
384+
return String(localized: "The app configuration is invalid.")
385+
case .caught(let error):
386+
return ErrorKit.userFriendlyMessage(for: error)
387+
}
388+
}
389+
}
390+
391+
// Usage is clean and simple:
392+
func appOperation() throws(AppError) {
393+
// Explicit error throwing for known cases
394+
guard configFileExists else {
395+
throw AppError.invalidConfiguration
396+
}
397+
398+
// Automatic wrapping for system errors and other error types
399+
try AppError.catch {
400+
try riskyOperation()
401+
try anotherRiskyOperation()
402+
}
403+
}
404+
```
405+
406+
### Benefits of Using `Catching`
407+
408+
- **Less Boilerplate**: No need for explicit wrapper cases for each error type
409+
- **Type Safety**: Maintains typed throws while simplifying error handling
410+
- **Clean Code**: Reduces error handling verbosity
411+
- **Automatic Message Propagation**: User-friendly messages flow through the error chain
412+
- **Easy Integration**: Works seamlessly with existing error types
413+
- **Return Value Support**: The `catch` function preserves return values from wrapped operations
414+
415+
### Best Practices
416+
417+
- Use `Catching` for error types that might wrap other errors
418+
- Keep error hierarchies shallow when possible
419+
- Use specific error cases for known errors, `caught` for others
420+
- Preserve user-friendly messages when wrapping errors
421+
- Consider error recovery strategies at each level
422+
423+
The `Catching` protocol makes error handling in Swift more intuitive and maintainable, especially in larger applications with complex error hierarchies. Combined with typed throws, it provides a powerful way to handle errors while keeping your code clean and maintainable.

Sources/ErrorKit/BuiltInErrors/DatabaseError.swift

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import Foundation
3434
/// }
3535
/// }
3636
/// ```
37-
public enum DatabaseError: Throwable {
37+
public enum DatabaseError: Throwable, Catching {
3838
/// The database connection failed.
3939
///
4040
/// # Example
@@ -100,6 +100,38 @@ public enum DatabaseError: Throwable {
100100
/// ```
101101
case generic(userFriendlyMessage: String)
102102

103+
/// Represents a child error (either from your own error types or unknown system errors) that was wrapped into this error type.
104+
/// This case is used internally by the ``catch(_:)`` function to store any errors thrown by the wrapped code.
105+
///
106+
/// # Example
107+
/// ```swift
108+
/// struct UserRepository {
109+
/// func fetchUserDetails(id: String) throws(DatabaseError) {
110+
/// // Check if user exists - simple case with explicit error
111+
/// guard let user = database.findUser(id: id) else {
112+
/// throw DatabaseError.recordNotFound(entity: "User", identifier: id)
113+
/// }
114+
///
115+
/// // Any errors from parsing or file access are automatically wrapped
116+
/// let preferences = try DatabaseError.catch {
117+
/// let prefsData = try fileManager.contents(ofFile: user.preferencesPath)
118+
/// return try JSONDecoder().decode(UserPreferences.self, from: prefsData)
119+
/// }
120+
///
121+
/// // Use the loaded preferences
122+
/// user.applyPreferences(preferences)
123+
/// }
124+
/// }
125+
/// ```
126+
///
127+
/// The `caught` case stores the original error while maintaining type safety through typed throws.
128+
/// Instead of manually catching and wrapping unknown errors, use the ``catch(_:)`` function
129+
/// which automatically wraps any thrown errors into this case.
130+
///
131+
/// - Parameters:
132+
/// - error: The original error that was wrapped into this error type.
133+
case caught(Error)
134+
103135
/// A user-friendly error message suitable for display to end users.
104136
public var userFriendlyMessage: String {
105137
switch self {
@@ -124,6 +156,8 @@ public enum DatabaseError: Throwable {
124156
)
125157
case .generic(let userFriendlyMessage):
126158
return userFriendlyMessage
159+
case .caught(let error):
160+
return ErrorKit.userFriendlyMessage(for: error)
127161
}
128162
}
129163
}

Sources/ErrorKit/BuiltInErrors/FileError.swift

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import Foundation
3434
/// }
3535
/// }
3636
/// ```
37-
public enum FileError: Throwable {
37+
public enum FileError: Throwable, Catching {
3838
/// The file could not be found.
3939
///
4040
/// # Example
@@ -95,6 +95,35 @@ public enum FileError: Throwable {
9595
/// ```
9696
case generic(userFriendlyMessage: String)
9797

98+
/// An error that occurred during a file operation, wrapped into this error type using the ``catch(_:)`` function.
99+
/// This could include system-level file errors, encoding/decoding errors, or any other errors encountered during file operations.
100+
///
101+
/// # Example
102+
/// ```swift
103+
/// struct DocumentStorage {
104+
/// func saveDocument(_ document: Document) throws(FileError) {
105+
/// // Regular error for missing file
106+
/// guard fileExists(document.path) else {
107+
/// throw FileError.fileNotFound(fileName: document.name)
108+
/// }
109+
///
110+
/// // Automatically wrap encoding and file system errors
111+
/// try FileError.catch {
112+
/// let data = try JSONEncoder().encode(document)
113+
/// try data.write(to: document.url, options: .atomic)
114+
/// }
115+
/// }
116+
/// }
117+
/// ```
118+
///
119+
/// The `caught` case stores the original error while maintaining type safety through typed throws.
120+
/// Instead of manually catching and wrapping system errors, use the ``catch(_:)`` function
121+
/// which automatically wraps any thrown errors into this case.
122+
///
123+
/// - Parameters:
124+
/// - error: The original error that occurred during the file operation.
125+
case caught(Error)
126+
98127
/// A user-friendly error message suitable for display to end users.
99128
public var userFriendlyMessage: String {
100129
switch self {
@@ -118,6 +147,8 @@ public enum FileError: Throwable {
118147
)
119148
case .generic(let userFriendlyMessage):
120149
return userFriendlyMessage
150+
case .caught(let error):
151+
return ErrorKit.userFriendlyMessage(for: error)
121152
}
122153
}
123154
}

Sources/ErrorKit/BuiltInErrors/GenericError.swift

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import Foundation
1515
/// userFriendlyMessage: String(localized: "The business data doesn't meet required criteria")
1616
/// )
1717
/// }
18-
/// // Continue with business logic
18+
///
19+
/// // Automatically wrap any other errors if needed
20+
/// try GenericError.catch {
21+
/// try validateAdditionalRules(data)
22+
/// }
1923
/// }
2024
/// }
2125
/// ```
@@ -33,7 +37,7 @@ import Foundation
3337
/// }
3438
/// }
3539
/// ```
36-
public struct GenericError: Throwable {
40+
public struct GenericError: Throwable, Catching {
3741
/// A user-friendly message describing the error.
3842
public let userFriendlyMessage: String
3943

@@ -56,4 +60,30 @@ public struct GenericError: Throwable {
5660
public init(userFriendlyMessage: String) {
5761
self.userFriendlyMessage = userFriendlyMessage
5862
}
63+
64+
/// Creates a new generic error that wraps another error.
65+
/// Used internally by the ``catch(_:)`` function to automatically wrap any thrown errors.
66+
///
67+
/// # Example
68+
/// ```swift
69+
/// struct FileProcessor {
70+
/// func processUserData() throws(GenericError) {
71+
/// // Explicit throwing for validation
72+
/// guard isValidPath(userDataPath) else {
73+
/// throw GenericError(userFriendlyMessage: "Invalid file path selected")
74+
/// }
75+
///
76+
/// // Automatically wrap any file system or JSON errors
77+
/// let userData = try GenericError.catch {
78+
/// let data = try Data(contentsOf: userDataPath)
79+
/// return try JSONDecoder().decode(UserData.self, from: data)
80+
/// }
81+
/// }
82+
/// }
83+
/// ```
84+
///
85+
/// - Parameter error: The error to be wrapped.
86+
public static func caught(_ error: Error) -> Self {
87+
GenericError(userFriendlyMessage: ErrorKit.userFriendlyMessage(for: error))
88+
}
5989
}

Sources/ErrorKit/BuiltInErrors/NetworkError.swift

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import Foundation
3939
/// }
4040
/// }
4141
/// ```
42-
public enum NetworkError: Throwable {
42+
public enum NetworkError: Throwable, Catching {
4343
/// No internet connection is available.
4444
///
4545
/// # Example
@@ -145,6 +145,35 @@ public enum NetworkError: Throwable {
145145
/// ```
146146
case generic(userFriendlyMessage: String)
147147

148+
/// An error that occurred during a network operation, wrapped into this error type using the ``catch(_:)`` function.
149+
/// This could include URLSession errors, SSL/TLS errors, or any other errors encountered during network communication.
150+
///
151+
/// # Example
152+
/// ```swift
153+
/// struct APIClient {
154+
/// func fetchUserProfile(id: String) throws(NetworkError) {
155+
/// // Regular error for no connectivity
156+
/// guard isNetworkReachable else {
157+
/// throw NetworkError.noInternet
158+
/// }
159+
///
160+
/// // Automatically wrap URLSession and decoding errors
161+
/// let profile = try NetworkError.catch {
162+
/// let (data, response) = try await URLSession.shared.data(from: userProfileURL)
163+
/// return try JSONDecoder().decode(UserProfile.self, from: data)
164+
/// }
165+
/// }
166+
/// }
167+
/// ```
168+
///
169+
/// The `caught` case stores the original error while maintaining type safety through typed throws.
170+
/// Instead of manually catching and wrapping system errors, use the ``catch(_:)`` function
171+
/// which automatically wraps any thrown errors into this case.
172+
///
173+
/// - Parameters:
174+
/// - error: The original error that occurred during the network operation.
175+
case caught(Error)
176+
148177
/// A user-friendly error message suitable for display to end users.
149178
public var userFriendlyMessage: String {
150179
switch self {
@@ -185,6 +214,8 @@ public enum NetworkError: Throwable {
185214
)
186215
case .generic(let userFriendlyMessage):
187216
return userFriendlyMessage
217+
case .caught(let error):
218+
return ErrorKit.userFriendlyMessage(for: error)
188219
}
189220
}
190221
}

0 commit comments

Comments
 (0)