Skip to content

Commit 2c1aa89

Browse files
authored
Merge pull request #35 from FlineDev/wip/error-mapper
Add `ErrorMapper` protocol for registering custom error mappers
2 parents 4fa4af8 + 8ff4084 commit 2c1aa89

File tree

7 files changed

+275
-68
lines changed

7 files changed

+275
-68
lines changed

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
![ErrorKit Logo](https://github.com/FlineDev/ErrorKit/blob/main/Logo.png?raw=true)
22

3+
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FFlineDev%2FErrorKit%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/FlineDev/ErrorKit)
34
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FFlineDev%2FErrorKit%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/FlineDev/ErrorKit)
45

56
# ErrorKit
@@ -69,7 +70,7 @@ do {
6970
}
7071
```
7172

72-
These enhanced descriptions are community-provided and fully localized mappings of common system errors to clearer, more actionable messages.
73+
These enhanced descriptions are community-provided and fully localized mappings of common system errors to clearer, more actionable messages. ErrorKit comes with built-in mappers for Foundation, CoreData, MapKit, and more. You can also create custom mappers for third-party libraries or your own error types.
7374

7475
[Read more about Enhanced Error Descriptions →](https://swiftpackageindex.com/FlineDev/ErrorKit/documentation/errorkit/enhanced-error-descriptions)
7576

@@ -179,7 +180,7 @@ Button("Report a Problem") {
179180
)
180181
```
181182

182-
With just a simple SwiftUI modifier, you can automatically include all log messages from Apple's unified logging system.
183+
With just a simple built-in SwiftUI modifier and the `logAttachment` helper function, you can easily include all log messages from Apple's unified logging system and let your users send them to you via email. Other integrations are also supported.
183184

184185
[Read more about User Feedback and Logging →](https://swiftpackageindex.com/FlineDev/ErrorKit/documentation/errorkit/user-feedback-with-logs)
185186

@@ -193,6 +194,8 @@ ErrorKit's features are designed to complement each other while remaining indepe
193194

194195
3. **Save time with ready-made tools**: built-in error types for common scenarios and simple log collection for user feedback.
195196

197+
4. **Extend with custom mappers**: Create error mappers for any library to improve error messages across your entire application.
198+
196199
## Adoption Path
197200

198201
Here's a practical adoption strategy:

Sources/ErrorKit/ErrorKit.docc/Guides/Enhanced-Error-Descriptions.md

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -75,18 +75,18 @@ do {
7575
}
7676
```
7777

78-
If the error already conforms to `Throwable`, its `userFriendlyMessage` is used. For system errors, ErrorKit provides an enhanced description from its built-in mappings.
78+
If the error already conforms to `Throwable`, its `userFriendlyMessage` is used. For system errors, ErrorKit provides an enhanced description from its built-in mappers.
7979

8080
### Localization Support
8181

82-
All enhanced error messages are fully localized using the `String.localized(key:defaultValue:)` pattern, ensuring users receive messages in their preferred language where available.
82+
All enhanced error messages are fully localized using the `String(localized:)` pattern, ensuring users receive messages in their preferred language where available.
8383

8484
### How It Works
8585

8686
The `userFriendlyMessage(for:)` function follows this process to determine the best error message:
8787

8888
1. If the error conforms to `Throwable`, it uses the error's own `userFriendlyMessage`
89-
2. It tries domain-specific handlers to find available enhanced versions
89+
2. It queries registered error mappers to find enhanced descriptions
9090
3. If the error conforms to `LocalizedError`, it combines its localized properties
9191
4. As a fallback, it formats the NSError domain and code along with the standard `localizedDescription`
9292

@@ -95,32 +95,57 @@ The `userFriendlyMessage(for:)` function follows this process to determine the b
9595
You can help improve ErrorKit by contributing better error descriptions for common error types:
9696

9797
1. Identify cryptic error messages from system frameworks
98-
2. Implement domain-specific handlers or extend existing ones (see folder `EnhancedDescriptions`)
98+
2. Implement domain-specific handlers or extend existing ones (see folder `ErrorMappers`)
9999
3. Use clear, actionable language that helps users understand what went wrong
100100
4. Include localization support for all messages (no need to actually localize, we'll take care)
101101

102102
Example contribution to handle a new error type:
103103

104104
```swift
105-
// In ErrorKit+Foundation.swift
105+
// In FoundationErrorMapper.swift
106106
case let jsonError as NSError where jsonError.domain == NSCocoaErrorDomain && jsonError.code == 3840:
107-
return String.localized(
108-
key: "EnhancedDescriptions.JSONError.invalidFormat",
109-
defaultValue: "The data couldn't be read because it isn't in the correct format."
110-
)
107+
return String(localized: "The data couldn't be read because it isn't in the correct format.")
111108
```
112109

110+
### Custom Error Mappers
111+
112+
While ErrorKit focuses on enhancing system and framework errors, you can also create custom mappers for any library:
113+
114+
```swift
115+
enum MyLibraryErrorMapper: ErrorMapper {
116+
static func userFriendlyMessage(for error: Error) -> String? {
117+
switch error {
118+
case let libraryError as MyLibrary.Error:
119+
switch libraryError {
120+
case .apiKeyExpired:
121+
return String(localized: "API key expired. Please update your credentials.")
122+
default:
123+
return nil
124+
}
125+
default:
126+
return nil
127+
}
128+
}
129+
}
130+
131+
// On app start:
132+
ErrorKit.registerMapper(MyLibraryErrorMapper.self)
133+
```
134+
135+
This extensibility allows the community to create mappers for 3rd-party libraries with known error issues.
136+
113137
## Topics
114138

115139
### Essentials
116140

117141
- ``ErrorKit/userFriendlyMessage(for:)``
142+
- ``ErrorMapper``
118143

119-
### Domain-Specific Handlers
144+
### Built-in Mappers
120145

121-
- ``ErrorKit/userFriendlyFoundationMessage(for:)``
122-
- ``ErrorKit/userFriendlyCoreDataMessage(for:)``
123-
- ``ErrorKit/userFriendlyMapKitMessage(for:)``
146+
- ``FoundationErrorMapper``
147+
- ``CoreDataErrorMapper``
148+
- ``MapKitErrorMapper``
124149

125150
### Continue Reading
126151

Sources/ErrorKit/ErrorKit.swift

Lines changed: 125 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@ public enum ErrorKit {
1111
/// This function analyzes the given `Error` and returns a clearer, more helpful message than the default system-provided description.
1212
/// All descriptions are localized, ensuring that users receive messages in their preferred language where available.
1313
///
14-
/// The list of user-friendly messages is maintained and regularly improved by the developer community. Contributions are welcome—if you find bugs or encounter new errors, feel free to submit a pull request (PR) for review.
14+
/// The function uses registered error mappers to generate contextual messages for errors from different frameworks and libraries.
15+
/// ErrorKit includes built-in mappers for `Foundation`, `CoreData`, `MapKit`, and more.
16+
/// You can extend ErrorKit's capabilities by registering custom mappers using ``registerMapper(_:)``.
17+
/// Custom mappers are queried in reverse order, meaning user-provided mappers take precedence over built-in ones.
1518
///
16-
/// Errors from various domains, such as `Foundation`, `CoreData`, `MapKit`, and more, are supported. As the project evolves, additional domains may be included to ensure comprehensive coverage.
19+
/// The list of user-friendly messages is maintained and regularly improved by the developer community.
20+
/// Contributions are welcome—if you find bugs or encounter new errors, feel free to submit a pull request (PR) for review.
1721
///
1822
/// - Parameter error: The `Error` instance for which a user-friendly message is needed.
1923
/// - Returns: A `String` containing an enhanced, localized, user-readable error message.
@@ -35,19 +39,14 @@ public enum ErrorKit {
3539
return throwable.userFriendlyMessage
3640
}
3741

38-
if let foundationDescription = Self.userFriendlyFoundationMessage(for: error) {
39-
return foundationDescription
40-
}
41-
42-
if let coreDataDescription = Self.userFriendlyCoreDataMessage(for: error) {
43-
return coreDataDescription
44-
}
45-
46-
if let mapKitDescription = Self.userFriendlyMapKitMessage(for: error) {
47-
return mapKitDescription
42+
// Check if a custom mapping was registered (in reverse order to prefer user-provided over built-in mappings)
43+
for errorMapper in self.errorMappers.reversed() {
44+
if let mappedMessage = errorMapper.userFriendlyMessage(for: error) {
45+
return mappedMessage
46+
}
4847
}
4948

50-
// LocalizedError: The recommended error type to conform to in Swift by default.
49+
// LocalizedError: The officially recommended error type to conform to in Swift, prefer over NSError
5150
if let localizedError = error as? LocalizedError {
5251
return [
5352
localizedError.errorDescription,
@@ -56,11 +55,13 @@ public enum ErrorKit {
5655
].compactMap(\.self).joined(separator: " ")
5756
}
5857

59-
// Default fallback (adds domain & code at least)
58+
// Default fallback (adds domain & code at least) – since all errors conform to NSError
6059
let nsError = error as NSError
6160
return "[\(nsError.domain): \(nsError.code)] \(nsError.localizedDescription)"
6261
}
6362

63+
// MARK: - Error Chain
64+
6465
/// Generates a detailed, hierarchical description of an error chain for debugging purposes.
6566
///
6667
/// This function provides a comprehensive view of nested errors, particularly useful when errors are wrapped through multiple layers
@@ -153,6 +154,46 @@ public enum ErrorKit {
153154
return Self.chainDescription(for: error, indent: "", enclosingType: type(of: error))
154155
}
155156

157+
private static func chainDescription(for error: Error, indent: String, enclosingType: Any.Type?) -> String {
158+
let mirror = Mirror(reflecting: error)
159+
160+
// Helper function to format the type name with optional metadata
161+
func typeDescription(_ error: Error, enclosingType: Any.Type?) -> String {
162+
let typeName = String(describing: type(of: error))
163+
164+
// For structs and classes (non-enums), append [Struct] or [Class]
165+
if mirror.displayStyle != .enum {
166+
let isClass = Swift.type(of: error) is AnyClass
167+
return "\(typeName) [\(isClass ? "Class" : "Struct")]"
168+
} else {
169+
// For enums, include the full case description with type name
170+
if let enclosingType {
171+
return "\(enclosingType).\(error)"
172+
} else {
173+
return String(describing: error)
174+
}
175+
}
176+
}
177+
178+
// Check if this is a nested error (conforms to Catching and has a caught case)
179+
if let caughtError = mirror.children.first(where: { $0.label == "caught" })?.value as? Error {
180+
let currentErrorType = type(of: error)
181+
let nextIndent = indent + " "
182+
return """
183+
\(currentErrorType)
184+
\(indent)└─ \(Self.chainDescription(for: caughtError, indent: nextIndent, enclosingType: type(of: caughtError)))
185+
"""
186+
} else {
187+
// This is a leaf node
188+
return """
189+
\(typeDescription(error, enclosingType: enclosingType))
190+
\(indent)└─ userFriendlyMessage: \"\(Self.userFriendlyMessage(for: error))\"
191+
"""
192+
}
193+
}
194+
195+
// MARK: - Grouping ID
196+
156197
/// Generates a stable identifier that groups similar errors based on their type structure.
157198
///
158199
/// While ``errorChainDescription(for:)`` provides a detailed view of an error chain including all parameters and messages,
@@ -224,41 +265,78 @@ public enum ErrorKit {
224265
return String(fullHash.prefix(6))
225266
}
226267

227-
private static func chainDescription(for error: Error, indent: String, enclosingType: Any.Type?) -> String {
228-
let mirror = Mirror(reflecting: error)
268+
// MARK: - Error Mapping
229269

230-
// Helper function to format the type name with optional metadata
231-
func typeDescription(_ error: Error, enclosingType: Any.Type?) -> String {
232-
let typeName = String(describing: type(of: error))
233-
234-
// For structs and classes (non-enums), append [Struct] or [Class]
235-
if mirror.displayStyle != .enum {
236-
let isClass = Swift.type(of: error) is AnyClass
237-
return "\(typeName) [\(isClass ? "Class" : "Struct")]"
238-
} else {
239-
// For enums, include the full case description with type name
240-
if let enclosingType {
241-
return "\(enclosingType).\(error)"
242-
} else {
243-
return String(describing: error)
244-
}
245-
}
270+
/// Registers a custom error mapper to extend ErrorKit's error mapping capabilities.
271+
///
272+
/// This function allows you to add your own error mapper for specific frameworks, libraries, or custom error types.
273+
/// Registered mappers are queried in reverse order to ensure user-provided mappers takes precedence over built-in ones.
274+
///
275+
/// # Usage
276+
/// Register error mappers during your app's initialization, typically in the App's initializer or main function:
277+
/// ```swift
278+
/// @main
279+
/// struct MyApp: App {
280+
/// init() {
281+
/// ErrorKit.registerMapper(MyDatabaseErrorMapper.self)
282+
/// ErrorKit.registerMapper(AuthenticationErrorMapper.self)
283+
/// }
284+
///
285+
/// var body: some Scene {
286+
/// // ...
287+
/// }
288+
/// }
289+
/// ```
290+
///
291+
/// # Best Practices
292+
/// - Register mappers early in your app's lifecycle
293+
/// - Order matters: Register more specific mappers after general ones (last added is checked first)
294+
/// - Avoid redundant mappers for the same error types (as this may lead to confusion)
295+
///
296+
/// # Example Mapper
297+
/// ```swift
298+
/// enum PaymentServiceErrorMapper: ErrorMapper {
299+
/// static func userFriendlyMessage(for error: Error) -> String? {
300+
/// switch error {
301+
/// case let paymentError as PaymentService.Error:
302+
/// switch paymentError {
303+
/// case .cardDeclined:
304+
/// return String(localized: "Payment declined. Please try a different card.")
305+
/// case .insufficientFunds:
306+
/// return String(localized: "Insufficient funds. Please add money to your account.")
307+
/// case .expiredCard:
308+
/// return String(localized: "Card expired. Please update your payment method.")
309+
/// default:
310+
/// return nil
311+
/// }
312+
/// default:
313+
/// return nil
314+
/// }
315+
/// }
316+
/// }
317+
///
318+
/// ErrorKit.registerMapper(PaymentServiceErrorMapper.self)
319+
/// ```
320+
///
321+
/// - Parameter mapper: The error mapper type to register
322+
public static func registerMapper(_ mapper: ErrorMapper.Type) {
323+
self.errorMappersQueue.async(flags: .barrier) {
324+
self._errorMappers.append(mapper)
246325
}
326+
}
247327

248-
// Check if this is a nested error (conforms to Catching and has a caught case)
249-
if let caughtError = mirror.children.first(where: { $0.label == "caught" })?.value as? Error {
250-
let currentErrorType = type(of: error)
251-
let nextIndent = indent + " "
252-
return """
253-
\(currentErrorType)
254-
\(indent)└─ \(Self.chainDescription(for: caughtError, indent: nextIndent, enclosingType: type(of: caughtError)))
255-
"""
256-
} else {
257-
// This is a leaf node
258-
return """
259-
\(typeDescription(error, enclosingType: enclosingType))
260-
\(indent)└─ userFriendlyMessage: \"\(Self.userFriendlyMessage(for: error))\"
261-
"""
262-
}
328+
/// A built-in sync mechanism to avoid concurrent access to ``errorMappers``.
329+
private static let errorMappersQueue = DispatchQueue(label: "ErrorKit.ErrorMappers", attributes: .concurrent)
330+
331+
/// The collection of error mappers that ErrorKit uses to generate user-friendly messages.
332+
nonisolated(unsafe) private static var _errorMappers: [ErrorMapper.Type] = [
333+
FoundationErrorMapper.self,
334+
CoreDataErrorMapper.self,
335+
MapKitErrorMapper.self,
336+
]
337+
338+
/// Provides thread-safe read access to `_errorMappers` using a concurrent queue.
339+
private static var errorMappers: [ErrorMapper.Type] {
340+
self.errorMappersQueue.sync { self._errorMappers }
263341
}
264342
}

0 commit comments

Comments
 (0)