[swift-evolution] [Discussion] Adopting a new common error type outside the bounds of NSError
Brent Royal-Gordon
brent at architechies.com
Sun Mar 6 20:38:47 CST 2016
> I wouldn’t want a single, universal error type for the standard library. I would want to be able to selectively catch errors based on their type rather than having to look into the “reason” string to determine what happened. (In other words, the reason should be encoded in the error type.)
I agree. A single concrete error type with stringly-typed data inside it is the precise *opposite* of how the error mechanism should be used.
Swift errors use types and cases to clearly categorize errors and make them easy to programmatically match; they then use associated values to explicitly model the details of each type of error, and pattern matching to allow `catch` clauses to easily zero in on specific errors they can handle, no matter how finicky the requirements. When you start throwing around concrete `Error` structs with `reason` strings and unstructured bags of data called `infoDictionary`, you severely handicap every one of those features.
In general, I think this is tackling the wrong problem. Each error type can manage its own storage containing whatever that type happens to need. Swift will automatically copy or move that information around as appropriate. Simply put, there's nothing we need to do there.
There are, however, two broad categories of things I *do* think should be modeled better:
- The mapping between a Swift Error and an NSError.
- The way errors are presented to the user.
So here's what I suggest we do to Error in Swift 3.
Clarify and Formalize NSError Bridging
----------------------------------------------------
Currently, the only requirements of `Error` are the hidden `_domain` and `_code` types. Let's formalize those. We'll also add support for `userInfo`, and include a constructor so we can unpack `userInfo` in the other direction.
public protocol Error {
var domain: String { get }
@_numbered var code: Int { get }
var userInfo: [String: AnyObject] { get }
init(code: Int, userInfo: [String: AnyObject])
}
extension Error {
public var domain: String {
return String(reflecting: self)
}
public var userInfo: [String: AnyObject] {
return [:]
}
@_numbered init(code: Int, userInfo: [String: AnyObject]) {
self.init(_numbered: code)
}
}
public extension Any<Error> {
public init(_: NSError) { ...omitted... }
}
public extension NSError {
public init(_: Error) { ...omitted... }
}
`code` is still magically synthesized, as indicated by the imaginary `@_numbered` attribute. I don't like that very much, but I don't see a good alternative to it, either—RawRepresentable synthesis *almost* works, except that cases with associated values wouldn't be supported.
CustomStringConvertible for Simple Error Messages
----------------------------------------------------------------------
I think that we should encourage developers to conform errors with user-readable error messages to CustomStringConvertible and implement `description` to return that error. To that end, when a CustomStringConvertible Swift error is bridged to NSError, its `description` should become the NSError's `localizedDescription`. That ends up looking like this:
enum HollywoodCarError: Error, CustomStringConvertible {
case exploded
case engineFellOut
var description: String {
switch self {
case .exploded:
return localized("The car has exploded in a fireball.")
case .engineFellOut:
return localized("The car's engine has fallen out.")
}
}
}
(The `CustomStringConvertible` documentation is not entirely clear on whether `description` should be user-readable and localized. I interpret it to be "yes", but if not, we can provide a `LocalizedStringConvertible` protocol along the same lines. In either case, the point is that this is *not* a feature limited to errors; there really ought to be a protocol for anything which can be shown to users in a plain-text form.)
PresentableError
-----------------------
There are two other parts of NSError that I think we should widely model because they'd be useful on all platforms.
One is the ability to provide a more detailed error message. NSError calls this the `localizedRecoverySuggestion`; I recommend we just call it `suggestion` or perhaps `details`.
The other is the ability to retrieve a terse sentence fragment explaining the error's cause, which can be inserted into a larger paragraph. NSError calls this `localizedFailureReason`; I suggest simply using `reason`.
Both of these fields should be bridged into their NSError equivalents, and conforming to their protocol would also imply conformance to `CustomStringConvertible`.
So here is what I would suggest:
protocol PresentableError: Error, CustomStringConvertible {
var suggestion: String { get }
var reason: String { get }
}
Note that you can declare merely `PresentableError` in your error type, since it implies conformance to `Error`:
enum HollywoodCarError: PresentableError {
...as before...
var reason: String {
switch self {
case .exploded:
return localized("It gently tapped something.")
case .engineFellOut:
return localized("The writers want you to get into some wacky hijinks.")
}
var suggestion: String {
switch self {
case .exploded:
return localized("Put on sunglasses and walk away from the explosion at a measured pace.")
case .engineFellOut:
return localized("Hike to the nearest town and check into the only motel.")
}
}
}
RecoverableError
-----------------------
Lower-priority because it's not used *that* often, but I think it's worth modeling in the long run. It may make sense to put this in Foundation instead of stdlib, though.
The idea behind Cocoa's `recoveryAttempter` is good, but I think the design is a problem, especially in Corelibs Foundation where we won't be able to perform selectors. So I suggest an alternative design. I believe it can be bridged to NSError's approach, although it would not quite be a *trivial* bridging process.
First, the RecoveryAttempting protocol, which models one particular approach to recovering from the error:
// This is class-constrained in case `recover(from:completion:)` needs to be mutating.
public protocol RecoveryAttempting: class, CustomStringConvertible {
// The CustomStringConvertible name should be suitable for buttons.
var destructive: Bool
/// Attempt to recover from the error using this attempter. Calls `completion` when finished.
func recover(from error: Error, completion: (Bool, ErrorType?) -> Void)
}
extension RecoveryAttempting {
public var destructive: Bool { return false }
}
Second, the RecoverableError protocol:
public protocol RecoverableError: Error {
var recoveryAttempters: [Any<RecoveryAttempting>] { get }
}
We should document that you should only provide non-trivial recovery attempters (i.e. no Cancel options). Higher-level error presentation code can always do that.
Aside: What We Leave Behind
----------------------------------------
`helpAnchor` is not modeled because it is *extremely* platform-specific. (It might make sense for AppKit to provide a protocol to represent it, though.)
Programmatically useful data about the error, like `NSURLErrorKey` or even `NSUnderlyingErrorKey`, are not modeled because that information is only available from particular error types or even individual errors. Without knowing at least *something* about the error you're handling, it's hard to do anything useful with random pieces of information like this.
Concrete Convenience Types
----------------------------------------
Although so far I've only talked about providing protocols, I think there are some concrete types we should provide as well. For these I will provide only interfaces; the implementations should be reasonably straightforward.
The first is a concrete type conforming to `RecoveryAttempting` which takes a closure:
public final class RecoveryAttempter: RecoveryAttempting {
typealias Attempter = (ErrorType, (Bool, ErrorType?) -> Void) -> Void
public init(description: String, destructive: Bool = false, attempter: Attempter)
}
I would also like to offer wrapper types which allow you to easily change various aspects of error presentation, along these lines:
struct CustomizedError<ErrorType: Error>: Error, CustomStringConvertible {
init(_ original: ErrorType, description: String)
}
extension CustomizedError: PresentableError where ErrorType: PresentableError {}
extension CustomizedError: PresentableError where ErrorType: PresentableError {}
struct CustomizedPresentableError<ErrorType: Error>: PresentableError {
init(_ original: ErrorType, description: String, reason: String, suggestion: String)
}
extension CustomizedPresentableError where ErrorType: PresentableError {
init(_ original: ErrorType, description: String? = nil, reason: String? = nil, suggestion: String? = nil)
}
extension CustomizedPresentableError: RecoverableError where ErrorType: RecoverableError {}
struct AdditionalRecoveryError<ErrorType: Error>: RecoverableError {
init(_ original: ErrorType, recoveryAttempters: [RecoveryAttempter])
}
extension AdditionalRecoveryError where ErrorType: RecoverableError {
init(_ original: ErrorType, additionalRecoveryAttempters: [RecoveryAttempter])
}
extension AdditionalRecoveryError: CustomStringConvertible where ErrorType: CustomStringConvertible {}
extension AdditionalRecoveryError: PresentableError where ErrorType: PresentableError {}
However, these types present a significant challenge: They interfere with `catch` matching. One possible workaround would be to have `Error` itself include an `original` property and have `catch` match against that; most `Error` types would return `self`, but shallow, non-semantic wrappers like these would return the error they were wrapping. (Note that this is *not* the same as Foundation's `underlyingError`, which is why it's named differently.) This could make it a little tricky to rethrow errors, though—doing so might strip away the custom behavior wrapped around the error.
This could be interpreted as a sign that the problem needs to be factored differently by separating the *error* from the *error message*. But I don't see a clean way to do that; it would make implementing an `Error` type more cumbersome, it would still not solve the wrapping problem (unless the idea is that you should only tweak an error message once you're committed to presenting it), and it would create an impedance mismatch with `NSError`.
--
Brent Royal-Gordon
Architechies
More information about the swift-evolution
mailing list