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
- The problem
- The solution
- Erasing any error
- Your own error types
- Localized errors
- Platform support
- Documentation
- Installation
- License
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.
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.
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.
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, itscode/domainare carried over. - Otherwise, on platforms with full Foundation, the error is bridged through
NSErrorto recover itscodeanddomain; 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
// }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 allAnyError 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.
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 no NSError to draw from, so its code defaults to 0 and its domain
is derived from the error's type. Values that are already an AnyError or a CustomAnyError keep
their code and domain everywhere.
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
AnyErrorfrom values you already have, and AnyError(custom:), for erasing aCustomAnyErroryou 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.
You can add AnyError to an Xcode project by adding it as a package dependency.
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"),This library is released under the MIT license. See LICENSE for details.