mfb-technologies-inc/any-error-swift
A concrete, value-semantic, Hashable error type that erases errors on every Swift platform, with or without Foundation, while retaining all the important information carried in the original error.
Motivation
Swift's Error is a protocol, so the moment you need to store an error you reach for the existential any Error. That box is convenient to throw around, but it's a poor thing to hold onto. It isn't Equatable or Hashable, so a value type that stores one can't synthesize those conformances either, ruling it out of a feature's Equatable state, a Set, or a dictionary key. Getting anything useful out of it beyond a localizedDescription also means knowing which concrete type is inside and downcasting to it, whether that's a URLError, a database driver's error, or whatever else your dependencies happened to throw.
Where full Foundation is available you could bridge to NSError instead, which recovers some of this: an NSError is Equatable and Hashable, and it carries a code, a domain, and localized messages. But it's a reference type rather than a value, it doesn't preserve the name of the Swift type the error came from, and it depends on Foundation, which isn't available (or even desirable) on every platform Swift runs on.
AnyError is a plain value type that captures the useful parts of an error: its localizedDescription, code, domain, the name of the type it came from, and the rest of the LocalizedError details (failureReason, helpAnchor, and recoverySuggestion). It's Hashable and Equatable, it stores cleanly in your models whether or not Foundation is available, and it hides the implementation details of your dependencies behind a single, stable type.
The problem
Say your feature keeps its state in a value type, and part of that state is whether the last load failed:
struct ProfileState: Equatable {
var profile: Profile?
var loadError: (any Error)? // 👈 'any Error' is not Equatable
}This doesn't compile. Because any Error isn't Equatable, ProfileState can't synthesize the Equatable conformance it asks for:
🛑 Type 'ProfileState' does not conform to protocol 'Equatable'
The same is true for Hashable, and for putting an error in a Set or a dictionary key. And even where you don't need equality, an any Error is opaque: reading anything structured out of it, such as a code to branch on or a domain, means knowing which concrete type is inside and downcasting to it, whether that's a URLError, a driver-specific database error, or a third-party SDK's error.
The solution
Store an AnyError instead. Now ProfileState conforms to Equatable without complaint:
import AnyError
struct ProfileState: Equatable {
var profile: Profile?
var loadError: AnyError? // ✅ Equatable and Hashable
}Erase whatever was thrown at the point you catch it:
do {
state.profile = try await repository.fetch()
} catch {
state.loadError = AnyError(unknown: error) // 👈 erase whatever was thrown
}AnyError keeps the information you actually want to display or log, and drops the type you didn't want to leak. Say repository.fetch() failed with a URLError(.timedOut):
state.loadError?.localizedDescription // "The operation couldn’t be completed. (NSURLErrorDomain error -1001.)"
state.loadError?.code // -1001
state.loadError?.domain // "NSURLErrorDomain"
state.loadError?.originatingTypeName // "NSError"code and domain come straight from the error. originatingTypeName is "NSError" here because URLError (like most Foundation error types) bridges to NSError, so that is the concrete type AnyError(unknown:) sees. For an error type you own, conform it to CustomAnyError and its real Swift type name is retained instead.
Erasing any error
init(unknown:) accepts any error and erases it:
let erased = AnyError(unknown: URLError(.timedOut)) If it's already an AnyError, it's returned unchanged. If it conforms to CustomAnyError, its code/domain are carried over. * Otherwise, on platforms with full Foundation, the error is bridged through NSError to recover its code and domain; elsewhere those are derived from the error's type.
AnyError conforms to LocalizedError, CustomStringConvertible, and CustomDebugStringConvertible, so printing or logging it produces readable output:
print(erased)
// NSURLErrorDomain code=-1001: The operation couldn’t be completed. (NSURLErrorDomain error -1001.)
print(erased.debugDescription)
// AnyError {
// localizedDescription: The operation couldn’t be completed. (NSURLErrorDomain error -1001.),
// originatingTypeName: NSError,
// code: -1001,
// domain: NSURLErrorDomain
// }Your own error types
If you own an error type, conform it to CustomAnyError and it maps to AnyError automatically, with no bridging guesswork:
import AnyError
enum ProfileError: CustomAnyError {
case notFound
case unauthorized
var code: Int {
switch self {
case .notFound: 404
case .unauthorized: 401
}
}
var localizedDescription: String {
switch self {
case .notFound: "That profile could not be found."
case .unauthorized: "You are not allowed to view this profile."
}
}
}
let erased = AnyError(custom: ProfileError.notFound)
erased.code // 404
erased.domain // "ProfileError"Anywhere full Foundation is available, CustomAnyError also refines CustomNSError. That means even if your error is thrown as any Error, bridged to NSError, and handed back to you from some framework, you can recover the original:
let nsError = ProfileError.notFound as NSError
AnyError.original(from: nsError) // the original AnyError, code 404 and allLocalized errors
AnyError is itself a LocalizedError. Beyond localizedDescription, it captures the other three localization fields (failureReason, helpAnchor, and recoverySuggestion) from whatever error you erase, so nothing an alert or error UI needs is lost along the way:
let erased = AnyError(unknown: someLocalizedError)
erased.errorDescription // same as localizedDescription
erased.failureReason // preserved from the original
erased.helpAnchor // preserved from the original
erased.recoverySuggestion // preserved from the originalBecause AnyError conforms to LocalizedError, its stored localizedDescription stays authoritative even when the value is read back through any Error; the bridge reads errorDescription, which AnyError backs with its own stored description.
Platform support
AnyError is available everywhere Swift runs: Apple platforms (iOS, macOS, tvOS, watchOS, visionOS), Linux, Windows, and WebAssembly. It even works on platforms that ship only FoundationEssentials, or no Foundation at all.
The behavior that requires full Foundation (NSError bridging via init(nsError:) and AnyError.original(from:), and the CustomNSError refinement of CustomAnyError) is only compiled in where Foundation can be imported. Everything else behaves identically on every platform. On non-Apple platforms you can opt into full Foundation with the FullFoundation package trait, which is enabled by default.
[!NOTE] When full Foundation isn't imported or available, erasing an arbitrary error with
AnyError(unknown:)has noNSErrorto draw from, so itscodedefaults to0and itsdomainis derived from the error's type. Values that are already anAnyErroror aCustomAnyErrorkeep theircodeanddomaineverywhere.
Embedded Swift
In Embedded Swift there is no Error existential to type-cast against and no NSError to bridge through, so AnyError(unknown:) (which relies on both) is not available. The reduced API surface is:
the memberwise initializer, for constructing an AnyError from values you already have, and AnyError(custom:), for erasing a CustomAnyError you own.
Because reflection is also absent, a CustomAnyError conformer must supply defaultDomain and originatingTypeName explicitly there rather than relying on their reflection-derived defaults. The localized metadata (failureReason/helpAnchor/recoverySuggestion) is always nil in Embedded, as there is no LocalizedError existential to read it from.
Documentation
* 0.x.x
Installation
You can add AnyError to an Xcode project by adding it as a package dependency.
https://github.com/MFB-Technologies-Inc/any-error-swift
If you want to use AnyError in a SwiftPM project, add it to your Package.swift:
dependencies: [
.package(url: "https://github.com/MFB-Technologies-Inc/any-error-swift", from: "0.1.0")
]And then add the product to any target that needs access to the library:
.product(name: "AnyError", package: "any-error-swift"),License
This library is released under the MIT license. See LICENSE for details.
Package Metadata
Repository: mfb-technologies-inc/any-error-swift
Default branch: main
README: README.md