Search code examples
swifterror-handlingcasting

Does the way `as` works in Swift depends on context?


Apple provides the following example in their documentation:

do {
    try customThrow()
} catch MyError.specificError1 {
    print("Caught specific error #1")
} catch let error as MyError where error.code == .specificError2 {
    print("Caught specific error #2, ", error.localizedDescription)
    // Prints "Caught specific error #2. A customized error from MyErrorDomain."
} catch let error {
    fatalError("Some other error: \(error)")
}

Why does this line work:

} catch let error as MyError where error.code == .specificError2 {

yet the following line results in nil:

let e = error as? MyError

?

Either error can be casted to MyError or it cannot, correct? So how can these two lines produce different results? Does the behavior of as varies on context and is different in catch clauses?

Update

The linked documentation is about Cocoa errors, that means NSError objects. So to reproduce, you'd do something along these lines:

extern NSErrorDomain const MyErrorDomain;
typedef NS_ERROR_ENUM(MyErrorDomain, MyError) {
    specificError1 = 0,
    specificError2 = 1
};

then you create and pass the error in Obj-C, not in Swift:

NSError * error = [NSError errorWithDomain:MyErrorDomain
    code:specificError1 userInfo:nil];

[someObj handleError:error];

and in Swift you have:

func handleError ( error: NSError ) {
    if let e = error as? MyError {
        // Never happens, never true
    }
}

Yet when thrown as an error:

- (BOOL)throwError:(NSError **)outError {
    *outError = [NSError errorWithDomain:MyErrorDomain
        code:specificError1 userInfo:nil];
    return NO;
}

and then caught in Swift, this is supposed to work correctly according to Apple's documentation. And I wonder how this can be.

Update 2

Following the code path, the error was once passed along as CFErrorRef, which is possible thank to toll-free bridging but when the error is converted back to a NSError, it won't look the same to Swift anymore.

This is the Swift debugger output when the error was originally created:

(lldb) p error
(NSError) $R0 = 0x0000000100606ad0 domain: "MyErrorDomain" - code: 0 {
  _userInfo = 0x00007fff80983090
}

And this is the output after it traveled around as a CFErrorRef and becomes a NSError again:

(lldb) p error
(NSError) $R1 = 0x0000000105505360 domain: "MyErrorDomain" - code: 0 {
  ObjectiveC.NSObject = {
    baseNSObject@0 = {
      isa = __SwiftNativeNSError
    }
    _reserved = 0x0000000000000000
    _code = 0
    _domain = 0x0000000100004230 "MyErrorDomain"
    _userInfo = 0x00007fff80983090
  }
}

As you can see, it's the same error. Same domain, same code but now it's not castable anymore to MyError.


Solution

  • Okay, the problem is that Apple does some kind of magic with NSError that is lost along the following way:

    ObjC -> C -> Swift -> ObjC -> Swift

    Apparently not all NSError are the same to Swift, even though they are to Obj-C.

    To pass that error through C, it must be a CFErrorRef. To get the error back into Obj-C, it must be a NSError again, but the Swift Code retrieves the error as Unmanaged<CFError>?.

    The code that converted the error back did the following:

    if let unmanagedError = errorRaw {
        let error = unmanagedError.takeRetainedValue() as Error as NSError
        if error.domain == "..." {
    

    Only for specific domains it needs to forward the code Obj-C, hence the double cast, as casting directly to NSError is not allowed. The problem is now, if that error is finally given back to Swift code, it cannot be casted to a Swift error any longer, despite having the correct domain and code of such an error.

    The actual fix that works is to modify the code above as follows:

    let cfe = unmanagedError.takeRetainedValue()
    let nse = cfe as Error as NSError
    let error = NSError(domain: nse.domain,
        code: nse.code, userInfo: nse.userInfo);
    if error.domain == "..." {
    

    As stupid as this code may seem, it does make a difference to Swift whether the error was casted or re-created, while the Obj-C in between simply won't care as for that code it makes no difference at all.

    Summary

    The behavior of as does not depend on context but it behaves on more than just types. Unlike a cast in C/Obj-C, the rule that "you either can always cast type A to type B or never do so" does not apply to Swift, since "as" dynamic casting and if a cast is possible at runtime and not can depend on more than just source and destination types.

    In case of NSError it also depends on property values (domain and code) and also on the internal representation of the object. While the first fact makes sense, second one does not and IMHO is an implementation quirk of the Swift runtime, as when comparing those two different errors above using isEqual:, then they actually are and for equal objects, the same cast rules should apply as equality means these objects are "interchangeable", which they are apparently not if one of them can be successfully casted and the other one not.