Skip to content

Commit f0d891f

Browse files
committed
Add new ErrorMapper protocol and register function for custom mapping
1 parent 4fa4af8 commit f0d891f

5 files changed

Lines changed: 232 additions & 53 deletions

File tree

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
}

Sources/ErrorKit/ErrorMapper.swift

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/// A protocol for mapping domain-specific errors to user-friendly messages.
2+
///
3+
/// `ErrorMapper` allows users to extend ErrorKit's error mapping capabilities by providing custom mappings for errors from specific frameworks, libraries, or domains.
4+
///
5+
/// # Overview
6+
/// ErrorKit comes with built-in mappers for Foundation, CoreData, and MapKit errors.
7+
/// You can add your own mappers for other frameworks or custom error types using the ``ErrorKit/registerMapper(_:)`` function.
8+
/// ErrorKit will query all registered mappers in reverse order until one returns a non-nil result. This means, the last added mapper takes precedence.
9+
///
10+
/// # Example Implementation
11+
/// ```swift
12+
/// enum FirebaseErrorMapper: ErrorMapper {
13+
/// static func userFriendlyMessage(for error: Error) -> String? {
14+
/// switch error {
15+
/// case let authError as AuthErrorCode:
16+
/// switch authError.code {
17+
/// case .wrongPassword:
18+
/// return String(localized: "The password is incorrect. Please try again.")
19+
/// case .userNotFound:
20+
/// return String(localized: "No account found with this email address.")
21+
/// default:
22+
/// return nil
23+
/// }
24+
///
25+
/// case let firestoreError as FirestoreErrorCode.Code:
26+
/// switch firestoreError {
27+
/// case .permissionDenied:
28+
/// return String(localized: "You don't have permission to access this data.")
29+
/// case .unavailable:
30+
/// return String(localized: "The service is temporarily unavailable. Please try again later.")
31+
/// default:
32+
/// return nil
33+
/// }
34+
///
35+
/// case let storageError as StorageErrorCode:
36+
/// switch storageError {
37+
/// case .objectNotFound:
38+
/// return String(localized: "The requested file could not be found.")
39+
/// case .quotaExceeded:
40+
/// return String(localized: "Storage quota exceeded. Please try again later.")
41+
/// default:
42+
/// return nil
43+
/// }
44+
///
45+
/// default:
46+
/// return nil
47+
/// }
48+
/// }
49+
/// }
50+
///
51+
/// // Register during app initialization
52+
/// ErrorKit.registerMapper(FirebaseErrorMapper.self)
53+
/// ```
54+
///
55+
/// Your mapper will be called automatically when using ``ErrorKit/userFriendlyMessage(for:)``:
56+
/// ```swift
57+
/// do {
58+
/// let user = try await Auth.auth().signIn(withEmail: email, password: password)
59+
/// } catch {
60+
/// let message = ErrorKit.userFriendlyMessage(for: error)
61+
/// // Message will be generated from FirebaseErrorMapper for Auth/Firestore/Storage errors
62+
/// }
63+
/// ```
64+
public protocol ErrorMapper {
65+
/// Maps a given error to a user-friendly message if possible.
66+
///
67+
/// This function is called by ErrorKit when attempting to generate a user-friendly error message.
68+
/// It should check if the error is of a type it can handle and return an appropriate message, or return nil to allow other mappers to process the error.
69+
///
70+
/// # Implementation Guidelines
71+
/// - Return nil for errors your mapper doesn't handle
72+
/// - Always use String(localized:) for message localization
73+
/// - Keep messages clear, actionable, and non-technical
74+
/// - Avoid revealing sensitive information
75+
/// - Consider the user experience when crafting messages
76+
///
77+
/// # Example
78+
/// ```swift
79+
/// static func userFriendlyMessage(for error: Error) -> String? {
80+
/// switch error {
81+
/// case let databaseError as DatabaseLibraryError:
82+
/// switch databaseError {
83+
/// case .connectionTimeout:
84+
/// return String(localized: "Database connection timed out. Please try again.")
85+
/// case .queryExecution:
86+
/// return String(localized: "Database query failed. Please contact support.")
87+
/// default:
88+
/// return nil
89+
/// }
90+
/// default:
91+
/// return nil
92+
/// }
93+
/// }
94+
/// ```
95+
///
96+
/// - Note: Any error cases you don't provide a return value for will simply keep their original message. So only override the unclear ones or those that are not localized or you want other kinds of improvements for. No need to handle all possible cases just for the sake of it.
97+
///
98+
/// - Parameter error: The error to potentially map to a user-friendly message
99+
/// - Returns: A user-friendly message if this mapper can handle the error, or nil otherwise
100+
static func userFriendlyMessage(for error: Error) -> String?
101+
}

Sources/ErrorKit/EnhancedDescriptions/ErrorKit+CoreData.swift renamed to Sources/ErrorKit/ErrorMappers/CoreDataErrorMapper.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
import CoreData
33
#endif
44

5-
extension ErrorKit {
6-
static func userFriendlyCoreDataMessage(for error: Error) -> String? {
5+
enum CoreDataErrorMapper: ErrorMapper {
6+
static func userFriendlyMessage(for error: Error) -> String? {
77
#if canImport(CoreData)
88
let nsError = error as NSError
99

Sources/ErrorKit/EnhancedDescriptions/ErrorKit+Foundation.swift renamed to Sources/ErrorKit/ErrorMappers/FoundationErrorMapper.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import Foundation
33
import FoundationNetworking
44
#endif
55

6-
extension ErrorKit {
7-
static func userFriendlyFoundationMessage(for error: Error) -> String? {
6+
enum FoundationErrorMapper: ErrorMapper {
7+
static func userFriendlyMessage(for error: Error) -> String? {
88
switch error {
99

1010
// URLError: Networking errors

Sources/ErrorKit/EnhancedDescriptions/ErrorKit+MapKit.swift renamed to Sources/ErrorKit/ErrorMappers/MapKitErrorMapper.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
import MapKit
33
#endif
44

5-
extension ErrorKit {
6-
static func userFriendlyMapKitMessage(for error: Error) -> String? {
5+
enum MapKitErrorMapper: ErrorMapper {
6+
static func userFriendlyMessage(for error: Error) -> String? {
77
#if canImport(MapKit)
88
if let mkError = error as? MKError {
99
switch mkError.code {

0 commit comments

Comments
 (0)