Errors and Exceptions
|
This section documents modern Objective-C as implemented by Apple Clang in the current Xcode release — Objective-C 2.0 plus ARC, literals and subscripting, This content was generated with the assistance of AI and should be verified against developer.apple.com before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Objective-C separates two kinds of failure, and the separation is strict. Expected, recoverable conditions — a file that is missing, a network that is down, input that does not parse — are reported with NSError.
Programmer errors — an array index out of range, a nil argument where one is forbidden, an
unimplemented abstract method — raise exceptions, which you generally do not catch.
This is the opposite of the Java or C# convention, and it is the single most important thing to internalise about error handling here.
The NSError Pattern
The Out-Parameter Convention
A method that can fail returns a result and takes an NSError ** out-parameter:
- (nullable NSData *)loadDataFromURL:(NSURL *)url error:(NSError **)error;
- (BOOL)saveToURL:(NSURL *)url error:(NSError **)error; // BOOL when there is no result
NSError *error = nil;
NSData *data = [self loadDataFromURL:url error:&error];
if (!data) { // check the RETURN VALUE, not the error
NSLog(@"failed: %@", error.localizedDescription);
return;
}
[self process:data];
Check the return value, never the error object. A method is entitled to write something into error even
on success, and only the return value is contractually meaningful. This is the discipline Apple’s own APIs
follow, and code that inverts it (if (error) { … }) fails in surprising ways.
The error parameter itself may be NULL — a caller who does not care passes nil. So every implementation
must guard before writing through it:
- (nullable NSData *)loadDataFromURL:(NSURL *)url error:(NSError **)error {
if (!url.isFileURL) {
if (error) { // the mandatory NULL check
*error = [NSError errorWithDomain:MyErrorDomain
code:MyErrorCodeInvalidURL
userInfo:@{
NSLocalizedDescriptionKey: @"Only file URLs are supported.",
NSURLErrorKey: url
}];
}
return nil; // and return the failure value
}
…
}
Domains, Codes and userInfo
An NSError is three things: a domain (which subsystem), a code (which failure), and a userInfo
dictionary (everything else).
// In the header
extern NSErrorDomain const MyLibraryErrorDomain;
typedef NS_ERROR_ENUM(MyLibraryErrorDomain, MyLibraryError) {
MyLibraryErrorInvalidURL = 1,
MyLibraryErrorNetworkFailure = 2,
MyLibraryErrorParseFailure = 3
};
// In the implementation
NSErrorDomain const MyLibraryErrorDomain = @"com.example.MyLibrary.ErrorDomain";
NS_ERROR_ENUM ties the codes to their domain, which lets Swift import them as a proper Error type whose
cases can be matched in a catch. Use reverse-DNS domain names, and never reuse someone else’s domain.
The standard userInfo keys carry the user-facing text:
| Key | Property |
|---|---|
Contains |
|
|
What went wrong — shown to the user. |
|
|
Why it went wrong. |
|
|
What the user might do about it. |
|
|
The lower-level error that caused this one. |
|
— |
The file or URL involved. |
NSError *error = [NSError errorWithDomain:MyLibraryErrorDomain
code:MyLibraryErrorNetworkFailure
userInfo:@{
NSLocalizedDescriptionKey: NSLocalizedString(@"Could not download the file.", nil),
NSLocalizedFailureReasonErrorKey: NSLocalizedString(@"The server did not respond.", nil),
NSLocalizedRecoverySuggestionErrorKey: NSLocalizedString(@"Check your connection and try again.", nil),
NSUnderlyingErrorKey: underlyingError
}];
error.domain; // @"com.example.MyLibrary.ErrorDomain"
error.code; // 2
error.localizedDescription; // the localised string above
Wrap user-visible strings in NSLocalizedString. If you provide no description, localizedDescription falls
back to an unhelpful "The operation couldn’t be completed" message.
Propagating Errors
Wrap a lower-level error rather than discarding it, so the whole causal chain survives:
- (nullable Config *)loadConfigFromURL:(NSURL *)url error:(NSError **)error {
NSError *readError = nil;
NSData *data = [NSData dataWithContentsOfURL:url options:0 error:&readError];
if (!data) {
if (error) {
*error = [NSError errorWithDomain:MyLibraryErrorDomain
code:MyLibraryErrorNetworkFailure
userInfo:@{
NSLocalizedDescriptionKey: @"Could not read the configuration file.",
NSUnderlyingErrorKey: readError // preserve the cause
}];
}
return nil;
}
…
}
For an asynchronous API the same information travels in the completion block, and the convention is that
exactly one of the two parameters is non-nil:
- (void)loadConfigFromURL:(NSURL *)url
completion:(void (^)(Config * _Nullable config, NSError * _Nullable error))completion;
Exceptions
Objective-C has full exception support, and idiomatic code uses it almost exclusively to signal programmer errors rather than to handle anything.
@try {
[self riskyOperation];
}
@catch (NSException *exception) {
NSLog(@"%@: %@", exception.name, exception.reason);
}
@catch (id other) {
NSLog(@"a non-NSException object was thrown");
}
@finally {
[self cleanUp]; // runs whether or not an exception occurred
}
Throwing:
@throw [NSException exceptionWithName:NSInvalidArgumentException
reason:@"The index is out of bounds."
userInfo:@{ @"index": @(index) }];
// The shorthand for raising:
[NSException raise:NSInvalidArgumentException
format:@"Index %ld is out of bounds", (long)index];
Common system exception names: NSInvalidArgumentException, NSRangeException, NSInternalInconsistencyException,
NSGenericException, NSMallocException.
Why Exceptions Are for Programmer Errors Only
Four reasons, and they compound:
-
Cocoa is not exception-safe. Framework code does not unwind cleanly; an exception thrown through UIKit or AppKit can leave objects in an inconsistent state or leak.
-
ARC does not emit cleanup code for exception paths by default. Unwinding through ARC code leaks unless the whole program is compiled with
-fobjc-arc-exceptions, which costs performance and is off by default. -
The convention is universal. Every Apple API reports recoverable failure with
NSError, so a caller is not expecting to have to catch anything. -
They are slow to throw, though free when not thrown.
So: an exception in Objective-C means the program has a bug. The correct response is almost always to let it
crash, fix the bug, and ship. Catching an NSRangeException to "recover" hides a real defect.
The narrow exceptions to that rule are when a third-party or C++ library genuinely throws, when you are writing a top-level handler purely to log a crash before exiting, and at the boundary of a plug-in system.
Uncaught Exception Handlers
A last-chance hook, for logging only — the process is going to terminate regardless:
static void MyUncaughtExceptionHandler(NSException *exception) {
NSLog(@"UNCAUGHT: %@\n%@", exception.reason, exception.callStackSymbols);
// Write a crash report. Do NOT attempt to continue: the process is unwinding.
}
int main(int argc, char *argv[]) {
@autoreleasepool {
NSSetUncaughtExceptionHandler(&MyUncaughtExceptionHandler);
…
}
}
exception.callStackSymbols and callStackReturnAddresses are what make this worth doing.
NSError or Exception?
How should it be reported?"] --> B{"Could this happen
in a correct, bug-free program?"} B -->|"No — it means
the code is wrong"| C["PROGRAMMER ERROR"] B -->|"Yes — the world is
just like that sometimes"| D["RECOVERABLE CONDITION"] C --> C1["Examples:
index out of range,
nil passed where nonnull required,
abstract method not overridden,
invalid state transition"] C1 --> C2["Raise an exception
NSAssert / NSParameterAssert
[NSException raise:...]
doesNotRecognizeSelector:"] C2 --> C3["Do NOT catch it.
Let it crash, then fix the bug."] D --> D1["Examples:
file not found,
network unreachable,
malformed user input,
permission denied"] D1 --> D2{"Synchronous
or asynchronous?"} D2 -->|synchronous| D3["Return nil / NO, and write
an NSError through the
NSError ** out-parameter"] D2 -->|asynchronous| D4["Pass an NSError into the
completion block
(result and error: exactly one non-nil)"] D3 --> D5["Caller checks the RETURN VALUE,
then reads the error"] D4 --> D5 style C2 fill:#f6e9cf,stroke:#b08a34 style C3 fill:#e05252,stroke:#8a1f1f,color:#fff style D3 fill:#dbe9d5,stroke:#4a7a3a style D4 fill:#dbe9d5,stroke:#4a7a3a style D5 fill:#dce9f7,stroke:#2f6fa8
Assertions
Assertions state an invariant and abort if it is violated. They are the idiomatic way to signal a programmer error at the point it is detected:
- (void)insertItem:(Item *)item atIndex:(NSUInteger)index {
NSParameterAssert(item); // an argument must be non-nil
NSAssert(index <= self.items.count,
@"Index %lu is out of bounds (count %lu)",
(unsigned long)index, (unsigned long)self.items.count);
…
}
| Macro | Use |
|---|---|
|
A general invariant, inside an Objective-C method. |
|
An argument precondition — the most common form. |
|
The same, inside a plain C function (no |
NSAssert is compiled out when NS_BLOCK_ASSERTIONS is defined, which Xcode does for Release builds by
default. Two consequences follow: never put side effects inside the condition, and do not rely on an assertion
to enforce something in production. For a check that must survive into release builds, raise an exception
explicitly.
Logging
NSLog
NSLog(@"Loaded %lu items from %@", (unsigned long)count, url);
NSLog(@"%@", error); // never NSLog(error) -- format-string bug
NSLog writes to the system log with a timestamp and process/thread identifiers. It is synchronous and
relatively expensive, and it remains in release builds unless you remove it — which matters both for
performance and because logs on a shipped device may expose user data.
The conventional guard:
#ifdef DEBUG
#define MyLog(fmt, ...) NSLog((@"%s:%d " fmt), __PRETTY_FUNCTION__, __LINE__, ##__VA_ARGS__)
#else
#define MyLog(...)
#endif
os_log
The modern replacement: faster, structured, with levels and subsystem categories, and privacy-aware.
#import <os/log.h>
static os_log_t MyLogger(void) {
static os_log_t logger;
static dispatch_once_t once;
dispatch_once(&once, ^{
logger = os_log_create("com.example.MyApp", "networking");
});
return logger;
}
os_log_debug(MyLogger(), "starting request to %{public}@", url);
os_log_info(MyLogger(), "received %lu bytes", (unsigned long)data.length);
os_log_error(MyLogger(), "request failed: %{public}@", error.localizedDescription);
os_log_fault(MyLogger(), "invariant violated");
Two features make it clearly preferable for anything shipping. Levels (debug, info, default,
error, fault) are filtered at read time rather than compiled out, so debug logging costs almost nothing
when nobody is listening. And dynamic strings are redacted by default — they appear as <private> unless
explicitly marked %{public}@, which keeps user data out of device logs without any effort on your part.
Read the results with Console.app, or log stream --predicate 'subsystem == "com.example.MyApp"'.
See Also
-
Lightweight Generics and Nullability — annotating
NSError **parameters correctly. -
Swift Interoperability — how an
NSError **method becomes a Swiftthrowsfunction. -
Blocks — the asynchronous completion-handler shape.
-
Build and Tooling — exception breakpoints and debugging.