Why can’t Swift catch an NSException, and how do you handle one thrown by Objective-C code?

4.10.5 · L3 · Interview

Predict first

An Objective-C helper wraps a call in @try/@catch and returns the exception name. What does this print?

// Objective-C: -[Risky itemAt:] reads @[@10, @20][i];  tryBlock(body, &reason) catches NSException
print(Risky().item(at: 1))
var reason: NSString?
let ok = tryBlock({ _ = Risky().item(at: 5) }, &reason)
print(ok, reason as Any)
  • 20 / false Optional(NSRangeException)
  • 20 / true nil
  • A crash on the second call

Predict first

Without the shim, the same bad index is called directly from Swift. What happens?

print(Risky().item(at: 9))
print("unreachable")
  • It prints 0 and continues
  • It terminates the process with an uncaught NSException

Mental model. NSError is a customer complaint form you can handle at the desk; an NSException is a fire alarm that clears the building, and Swift’s desk has no procedure for it.

Objective-C has two error systems. Recoverable failures use NSError **, which Swift imports as throws. NSException (@throw, @try/@catch) is for programmer errors: out-of-range indexes, unrecognised selectors, invalid arguments, a KVC miss. Swift’s do/catch does not catch it, and Swift code is not compiled to unwind through an Objective-C exception, so the process terminates (libc++abi: terminating due to uncaught exception of type NSException) rather than letting you recover. Letting one pass through Swift frames is undefined behaviour in general, which is another reason not to rely on it.

If you must survive one, put the call behind a small Objective-C shim with @try/@catch that converts the exception to an NSError or a boolean, and call that from Swift. Use it for a third-party library you cannot fix, or for KVC on user-provided key paths. Everywhere else treat an exception as a bug to prevent: check the index, check responds(to:), validate keys. Production crash reports with “NSRangeException” or “NSInvalidArgumentException” point to the offending call.

The shim catches the out-of-range exception and reports it as a result, so the Swift code carries on.

Swift has no handler for it, so the runtime aborts.

Common traps

  • Wrapping Objective-C calls in Swift do/catch and expecting it to stop an exception. The catch only sees thrown Swift errors.
  • Catching and continuing after an exception. Cocoa does not promise that the objects involved are in a consistent state, so apps usually should not continue.

They might follow up with

  • How do you see the exception that killed the app? The crash report’s “Last Exception Backtrace” and the reason string.
  • What does -fobjc-exceptions have to do with it? It is on for Objective-C by default; a shim only needs @try in an .m file.

Related questions

  • 4.4.3 How do Swift errors and Objective-C NSError translate across the border?
  • 23.5.1 How do you read a crash report, what do the exception types and Swift runtime messages mean, and how does…
  • 23.1.1 What kinds of breakpoints are there in Xcode and LLDB, and how do conditions, actions, symbolic and exception…

← How do you create or retarget a class at run time, and why does Swift…