Skip to content

Add ErrorMapper protocol for registering custom error mappers #35

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
![ErrorKit Logo](https://github.com/FlineDev/ErrorKit/blob/main/Logo.png?raw=true)

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

# ErrorKit
Expand Down Expand Up @@ -69,7 +70,7 @@ do {
}
```

These enhanced descriptions are community-provided and fully localized mappings of common system errors to clearer, more actionable messages.
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.

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

Expand Down Expand Up @@ -179,7 +180,7 @@ Button("Report a Problem") {
)
```

With just a simple SwiftUI modifier, you can automatically include all log messages from Apple's unified logging system.
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.

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

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

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

4. **Extend with custom mappers**: Create error mappers for any library to improve error messages across your entire application.

## Adoption Path

Here's a practical adoption strategy:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,18 +75,18 @@ do {
}
```

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

### Localization Support

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

### How It Works

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

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

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

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

Example contribution to handle a new error type:

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

### Custom Error Mappers

While ErrorKit focuses on enhancing system and framework errors, you can also create custom mappers for any library:

```swift
enum MyLibraryErrorMapper: ErrorMapper {
static func userFriendlyMessage(for error: Error) -> String? {
switch error {
case let libraryError as MyLibrary.Error:
switch libraryError {
case .apiKeyExpired:
return String(localized: "API key expired. Please update your credentials.")
default:
return nil
}
default:
return nil
}
}
}

// On app start:
ErrorKit.registerMapper(MyLibraryErrorMapper.self)
```

This extensibility allows the community to create mappers for 3rd-party libraries with known error issues.

## Topics

### Essentials

- ``ErrorKit/userFriendlyMessage(for:)``
- ``ErrorMapper``

### Domain-Specific Handlers
### Built-in Mappers

- ``ErrorKit/userFriendlyFoundationMessage(for:)``
- ``ErrorKit/userFriendlyCoreDataMessage(for:)``
- ``ErrorKit/userFriendlyMapKitMessage(for:)``
- ``FoundationErrorMapper``
- ``CoreDataErrorMapper``
- ``MapKitErrorMapper``

### Continue Reading

Expand Down
172 changes: 125 additions & 47 deletions Sources/ErrorKit/ErrorKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ public enum ErrorKit {
/// This function analyzes the given `Error` and returns a clearer, more helpful message than the default system-provided description.
/// All descriptions are localized, ensuring that users receive messages in their preferred language where available.
///
/// 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.
/// The function uses registered error mappers to generate contextual messages for errors from different frameworks and libraries.
/// ErrorKit includes built-in mappers for `Foundation`, `CoreData`, `MapKit`, and more.
/// You can extend ErrorKit's capabilities by registering custom mappers using ``registerMapper(_:)``.
/// Custom mappers are queried in reverse order, meaning user-provided mappers take precedence over built-in ones.
///
/// 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.
/// 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.
///
/// - Parameter error: The `Error` instance for which a user-friendly message is needed.
/// - Returns: A `String` containing an enhanced, localized, user-readable error message.
Expand All @@ -35,19 +39,14 @@ public enum ErrorKit {
return throwable.userFriendlyMessage
}

if let foundationDescription = Self.userFriendlyFoundationMessage(for: error) {
return foundationDescription
}

if let coreDataDescription = Self.userFriendlyCoreDataMessage(for: error) {
return coreDataDescription
}

if let mapKitDescription = Self.userFriendlyMapKitMessage(for: error) {
return mapKitDescription
// Check if a custom mapping was registered (in reverse order to prefer user-provided over built-in mappings)
for errorMapper in self.errorMappers.reversed() {
if let mappedMessage = errorMapper.userFriendlyMessage(for: error) {
return mappedMessage
}
}

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

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

// MARK: - Error Chain

/// Generates a detailed, hierarchical description of an error chain for debugging purposes.
///
/// This function provides a comprehensive view of nested errors, particularly useful when errors are wrapped through multiple layers
Expand Down Expand Up @@ -153,6 +154,46 @@ public enum ErrorKit {
return Self.chainDescription(for: error, indent: "", enclosingType: type(of: error))
}

private static func chainDescription(for error: Error, indent: String, enclosingType: Any.Type?) -> String {
let mirror = Mirror(reflecting: error)

// Helper function to format the type name with optional metadata
func typeDescription(_ error: Error, enclosingType: Any.Type?) -> String {
let typeName = String(describing: type(of: error))

// For structs and classes (non-enums), append [Struct] or [Class]
if mirror.displayStyle != .enum {
let isClass = Swift.type(of: error) is AnyClass
return "\(typeName) [\(isClass ? "Class" : "Struct")]"
} else {
// For enums, include the full case description with type name
if let enclosingType {
return "\(enclosingType).\(error)"
} else {
return String(describing: error)
}
}
}

// Check if this is a nested error (conforms to Catching and has a caught case)
if let caughtError = mirror.children.first(where: { $0.label == "caught" })?.value as? Error {
let currentErrorType = type(of: error)
let nextIndent = indent + " "
return """
\(currentErrorType)
\(indent)└─ \(Self.chainDescription(for: caughtError, indent: nextIndent, enclosingType: type(of: caughtError)))
"""
} else {
// This is a leaf node
return """
\(typeDescription(error, enclosingType: enclosingType))
\(indent)└─ userFriendlyMessage: \"\(Self.userFriendlyMessage(for: error))\"
"""
}
}

// MARK: - Grouping ID

/// Generates a stable identifier that groups similar errors based on their type structure.
///
/// While ``errorChainDescription(for:)`` provides a detailed view of an error chain including all parameters and messages,
Expand Down Expand Up @@ -224,41 +265,78 @@ public enum ErrorKit {
return String(fullHash.prefix(6))
}

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

// Helper function to format the type name with optional metadata
func typeDescription(_ error: Error, enclosingType: Any.Type?) -> String {
let typeName = String(describing: type(of: error))

// For structs and classes (non-enums), append [Struct] or [Class]
if mirror.displayStyle != .enum {
let isClass = Swift.type(of: error) is AnyClass
return "\(typeName) [\(isClass ? "Class" : "Struct")]"
} else {
// For enums, include the full case description with type name
if let enclosingType {
return "\(enclosingType).\(error)"
} else {
return String(describing: error)
}
}
/// Registers a custom error mapper to extend ErrorKit's error mapping capabilities.
///
/// This function allows you to add your own error mapper for specific frameworks, libraries, or custom error types.
/// Registered mappers are queried in reverse order to ensure user-provided mappers takes precedence over built-in ones.
///
/// # Usage
/// Register error mappers during your app's initialization, typically in the App's initializer or main function:
/// ```swift
/// @main
/// struct MyApp: App {
/// init() {
/// ErrorKit.registerMapper(MyDatabaseErrorMapper.self)
/// ErrorKit.registerMapper(AuthenticationErrorMapper.self)
/// }
///
/// var body: some Scene {
/// // ...
/// }
/// }
/// ```
///
/// # Best Practices
/// - Register mappers early in your app's lifecycle
/// - Order matters: Register more specific mappers after general ones (last added is checked first)
/// - Avoid redundant mappers for the same error types (as this may lead to confusion)
///
/// # Example Mapper
/// ```swift
/// enum PaymentServiceErrorMapper: ErrorMapper {
/// static func userFriendlyMessage(for error: Error) -> String? {
/// switch error {
/// case let paymentError as PaymentService.Error:
/// switch paymentError {
/// case .cardDeclined:
/// return String(localized: "Payment declined. Please try a different card.")
/// case .insufficientFunds:
/// return String(localized: "Insufficient funds. Please add money to your account.")
/// case .expiredCard:
/// return String(localized: "Card expired. Please update your payment method.")
/// default:
/// return nil
/// }
/// default:
/// return nil
/// }
/// }
/// }
///
/// ErrorKit.registerMapper(PaymentServiceErrorMapper.self)
/// ```
///
/// - Parameter mapper: The error mapper type to register
public static func registerMapper(_ mapper: ErrorMapper.Type) {
self.errorMappersQueue.async(flags: .barrier) {
self._errorMappers.append(mapper)
}
}

// Check if this is a nested error (conforms to Catching and has a caught case)
if let caughtError = mirror.children.first(where: { $0.label == "caught" })?.value as? Error {
let currentErrorType = type(of: error)
let nextIndent = indent + " "
return """
\(currentErrorType)
\(indent)└─ \(Self.chainDescription(for: caughtError, indent: nextIndent, enclosingType: type(of: caughtError)))
"""
} else {
// This is a leaf node
return """
\(typeDescription(error, enclosingType: enclosingType))
\(indent)└─ userFriendlyMessage: \"\(Self.userFriendlyMessage(for: error))\"
"""
}
/// A built-in sync mechanism to avoid concurrent access to ``errorMappers``.
private static let errorMappersQueue = DispatchQueue(label: "ErrorKit.ErrorMappers", attributes: .concurrent)

/// The collection of error mappers that ErrorKit uses to generate user-friendly messages.
nonisolated(unsafe) private static var _errorMappers: [ErrorMapper.Type] = [
FoundationErrorMapper.self,
CoreDataErrorMapper.self,
MapKitErrorMapper.self,
]

/// Provides thread-safe read access to `_errorMappers` using a concurrent queue.
private static var errorMappers: [ErrorMapper.Type] {
self.errorMappersQueue.sync { self._errorMappers }
}
}
Loading