Error Handling: NSError, NSException, and Choosing the Right Tool
Understand Objective-C's two distinct error-handling mechanisms — NSError for expected, recoverable failures and NSException/@try/@catch for exceptional, usually unrecoverable failures — and learn why the two are not interchangeable and exceptions should not be used as ordinary control flow.
Objective-C splits error handling into two mechanisms that look superficially similar but serve fundamentally different purposes, and confusing the two is one of the more consequential mistakes a developer coming from another language can make. NSError represents an expected, recoverable failure — a network request that failed, a file that doesn't exist, a JSON parse that didn't succeed — the kind of outcome a well-written method anticipates and reports through its normal return values. NSException, by contrast, represents an exceptional, usually unrecoverable failure — an out-of-bounds array access, an unrecognized selector call — the kind of thing that indicates a bug in the program itself rather than a routine operating condition. This lesson covers both mechanisms in depth, and spends real time on the distinction between them, because getting it backwards — treating ordinary, expected failures as exceptions to be thrown and caught, the way some other languages encourage — leads to Objective-C code that is both harder to reason about and, because of real interactions between exceptions and ARC, genuinely riskier.
NSError: The Standard Mechanism for Expected Failures
NSError is an ordinary Objective-C object — not a special language construct — that packages up information about a failure: a domain identifying what subsystem produced it, a numeric code identifying the specific failure within that domain, and a userInfo dictionary carrying additional context. Because it's just an object, passing information about a failure with NSError uses the same mechanism Objective-C uses everywhere else: an out-parameter, written NSError **, exactly as seen throughout the JSON lesson's NSJSONSerialization calls.
The idiomatic Objective-C pattern built around this is consistent across nearly the entire Foundation and platform SDK: a method that can fail takes a trailing NSError **error parameter, and on failure it returns a value indicating failure (commonly nil for a method that would otherwise return an object, or NO/false for one returning BOOL) while also populating *error with an NSError describing what went wrong. On success, it returns the real result and leaves the error pointer untouched (which is why callers initialize their local error variable to nil beforehand, as covered in the previous lesson).
The critical discipline this pattern demands of callers is: check both. A failed call is signaled primarily by its return value (nil, or NO) — that is the check that must never be skipped — and the populated NSError is there to explain why, for logging, for deciding whether to retry, or for showing the user something meaningful. Code that only checks one of the two either misses real failures or has no way to explain them when they occur.
NSErrorDomain and Error Codes
An NSError's domain — an NSString, conventionally declared as a constant with the NSErrorDomain typedef, like NSCocoaErrorDomain or a custom MyAppNetworkErrorDomain — namespaces its code so the same numeric code can mean different things in different subsystems without colliding. A code of 4 in one domain might mean 'file not found' while a code of 4 in a completely different, custom domain might mean 'invalid authentication token' — the domain is what disambiguates them.
In your own code, the standard pattern is to declare a dedicated domain constant and an enum of the specific failure codes your subsystem can produce, then construct NSError instances against that domain wherever your code detects one of those failure conditions. This gives calling code a reliable, structured way to distinguish 'this specific kind of failure happened' from just 'something went wrong', which matters whenever different failures call for different handling (retry a timeout, but don't retry an invalid-credentials failure, for example).
userInfo and Localized Descriptions
Beyond domain and code, an NSError carries a userInfo dictionary — arbitrary additional context about the failure. Foundation defines a handful of standard keys for common cases: NSLocalizedDescriptionKey for a human-readable description of what went wrong (accessible more conveniently through the NSError property .localizedDescription), NSLocalizedFailureReasonErrorKey and NSLocalizedRecoverySuggestionErrorKey for more detailed, user-facing explanations, and NSUnderlyingErrorKey for wrapping a lower-level NSError that caused this one (letting errors chain, the way a JSON-parsing failure might wrap the underlying parser's own error).
Populating NSLocalizedDescriptionKey when you construct your own NSError instances is worth doing even in code that mostly just logs errors during development, because it's the field that ends up in a debug log, an analytics report, or — if you're not careful to intercept it first — directly in front of a user, and a clear, specific description saves real debugging time later.
The nil-Return-Plus-Error-Out-Parameter Idiom
It's worth stating the idiomatic pattern explicitly, since it's easy to get subtly wrong in your own APIs: a method that can fail and would otherwise return an object should return nil on failure and populate the error out-parameter, and it should do both together, consistently. A method that sometimes returns nil without setting the error, or that sets the error but still returns a real-looking (but actually invalid) object, breaks the contract callers rely on and leads to exactly the kind of bug where checking only one signal misses the failure.
The example below shows this idiom on a hypothetical file-loading method: it returns nil and populates *error (through the address-of pattern) on failure, or returns real data and leaves the error pointer untouched on success — the same contract NSJSONSerialization itself follows.
NSException, @throw, and the @try/@catch/@finally Syntax
Objective-C's other error-handling mechanism is exceptions, built around the NSException class and four keywords: @try marks a block of code that might throw, @catch (NSException *e) { ... } catches an exception thrown from within the try block and gives you a chance to handle it, @finally { ... } runs unconditionally after the try/catch, whether or not an exception was thrown or caught, and @throw raises an exception, either an NSException you construct yourself or one re-thrown from inside a catch block.
An NSException carries a name (an NSString identifying the kind of exception, like NSInvalidArgumentException), a reason (a human-readable description), and its own userInfo dictionary for additional context — structurally similar to NSError's domain/code/userInfo, which is part of why the two are easy to confuse despite serving very different purposes.
Many of the exceptions you'll actually encounter in practice are raised by the Objective-C runtime and Foundation itself, not by your own @throw statements: calling an unrecognized selector on an object, accessing an array index that's out of bounds, or removing a key from an NSDictionary while iterating it, for example, all raise NSException instances automatically, without any explicit @throw in your own code.
The Critical Distinction: Expected Failure vs. Exceptional Failure
This is the conceptual center of this lesson, and the spec is emphatic about it: NSError models an expected, recoverable failure — the kind of outcome a correctly-written program anticipates as a normal possibility and handles routinely, like a network request timing out or a file not existing. NSException models an exceptional, usually unrecoverable programmer or runtime failure — the kind of outcome that indicates a bug, like an out-of-bounds array access or an unrecognized selector, rather than a normal operating condition a well-behaved caller should expect to hit.
The practical test for which one fits a given situation is roughly: would a correctly-written caller of this code, under entirely normal operating conditions, ever legitimately need to handle this outcome? A network timeout — yes, absolutely, any networked app will hit this regularly through no fault of its own code, so it's an NSError. An out-of-bounds array access — no, a correctly-written program never intentionally indexes past an array's bounds; when it happens, it's because of a bug, so it's raised as an NSException rather than something the array API expects you to routinely check for.
This distinction has real consequences for how you write code. A method whose job is 'try to load this file, which might not exist' should use NSError, because file-not-found is exactly the kind of routine, expected condition its callers need a clean, structured way to check for and handle on every call. A method should never use NSException for something like 'this required parameter was nil' as its primary error-reporting mechanism to callers that are expected to routinely recover from it — although, as covered next, the runtime itself does sometimes raise exceptions for programmer errors like passing nil where it's disallowed, precisely because that represents a bug to be fixed, not a condition to be gracefully handled at runtime.
Do Not Use Exceptions as Ordinary Control Flow
The spec's instruction not to teach exceptions as ordinary control flow deserves to be stated plainly: in Objective-C, unlike some other languages, most exceptions are not designed to be routinely caught and safely recovered from. Wrapping a @try/@catch around code purely to swallow expected failures — the way you might reach for try/catch in another language to handle a routine 'this might not exist' condition — is fighting against how the platform's exceptions are meant to be used, and NSError already exists precisely to handle that case cleanly.
There is also a concrete technical reason to be cautious here beyond style: ARC (Automatic Reference Counting)'s interaction with exceptions has real caveats. By default, ARC-generated code is not guaranteed to clean up (release) local strong references correctly when an exception unwinds through it unless the project is built with extra exception-safety support enabled, which most Objective-C projects are not, since it carries a performance cost. In practice, this means an exception thrown and caught in ordinary ARC code can leak memory or leave objects in an inconsistent state — a real cost most other languages' exception mechanisms don't carry, and a strong reason on top of the conceptual one to reserve exceptions for truly exceptional, program-ending-or-nearly-so situations rather than routine control flow.
The idiomatic takeaway: when you're designing a method that can fail in a way callers should expect and handle, reach for NSError and the nil-return-plus-out-parameter pattern, not @throw and @catch. Reserve exceptions for the case they were actually designed for — signaling that something has gone wrong at a level a normal, correctly-operating caller should never need to plan around, typically a bug rather than a routine failure mode.
A Worked @try/@catch/@finally Example
Even though exceptions shouldn't be reached for as routine control flow, understanding their mechanics matters — both because the runtime raises them for genuine programmer errors you may want to catch at a boundary (for logging before a crash, for instance) and because reading other code that uses them requires knowing the syntax cold.
The example below deliberately accesses an array out of bounds to demonstrate the mechanics: the @try block's remaining statements are skipped the instant the exception is thrown, control jumps to the matching @catch block, and the @finally block runs regardless of whether an exception occurred at all — useful for guaranteed cleanup code, like closing a file handle, that must run whether or not something went wrong.
Code Examples
This mirrors the idiom used throughout Foundation: the method returns nil on failure and relies on the same &error out-parameter pattern from the JSON lesson to explain why, and the call site checks the return value first (the primary success/failure signal) and only then reads error.localizedDescription for details. Note that stringValueOfFile:... itself already follows this exact contract, so this wrapper simply passes the error pointer straight through.
if (!contents) { return nil; } // return value is the primary failure signal
This shows the standard way to build application-specific NSError instances: a dedicated domain constant namespaces your codes away from Foundation's and other subsystems', an NS_ENUM gives the codes readable names instead of magic numbers, and NSLocalizedDescriptionKey in userInfo supplies the human-readable description that .localizedDescription will later return.
[NSError errorWithDomain:MyAppNetworkErrorDomain code:MyAppNetworkErrorCodeTimeout userInfo:@{ NSLocalizedDescriptionKey: @"..." }]
Indexing values[10] on a 3-element array raises an NSException (specifically an NSRangeException) the moment it executes; the remaining lines in the @try block are skipped entirely, execution jumps straight to @catch, and @finally then runs unconditionally. This demonstrates the mechanics of the syntax, but note this is exactly the kind of programmer-error case the previous section says should be fixed at the source (don't index out of bounds) rather than routinely caught in shipping code.
@catch (NSException *exception) { NSLog(@"%@ - %@", exception.name, exception.reason); }
Common mistake: Relying on @try/@catch here as if it were a safe way to 'handle' bad indexing in normal control flow, rather than fixing the logic that produced an invalid index in the first place.
- (nullable NSString *)loadFileAtPath:(NSString *)path error:(NSError **)error {
NSString *contents = [NSString stringWithContentsOfFile:path
encoding:NSUTF8StringEncoding
error:error];
if (!contents) {
// 'error' has already been populated by stringWithContentsOfFile:...
return nil;
}
return contents;
}
// Call site:
NSError *error = nil;
NSString *text = [self loadFileAtPath:@"/tmp/notes.txt" error:&error];
if (!text) {
NSLog(@"Failed to load file: %@", error.localizedDescription);
} else {
NSLog(@"Loaded %lu characters", (unsigned long)text.length);
}static NSErrorDomain const MyAppNetworkErrorDomain = @"com.example.MyApp.NetworkError";
typedef NS_ENUM(NSInteger, MyAppNetworkErrorCode) {
MyAppNetworkErrorCodeTimeout = 1,
MyAppNetworkErrorCodeInvalidResponse = 2
};
NSError *error = [NSError errorWithDomain:MyAppNetworkErrorDomain
code:MyAppNetworkErrorCodeTimeout
userInfo:@{
NSLocalizedDescriptionKey: @"The request timed out before the server responded."
}];NSArray *values = @[@"a", @"b", @"c"];
@try {
NSLog(@"Before the out-of-bounds access");
id item = values[10]; // raises NSRangeException
NSLog(@"This line never runs");
}
@catch (NSException *exception) {
NSLog(@"Caught exception: %@ - %@", exception.name, exception.reason);
}
@finally {
NSLog(@"This always runs, exception or not");
}NSError vs. NSException
NSError is for failures a well-written caller anticipates and checks for routinely; NSException is for failures that indicate a bug, not a normal operating condition — do not teach or use exceptions as ordinary control flow.
NSError
↓
Expected recoverable failure
NSException
↓
Exceptional programmer/runtime failureImportant Rules
Use NSError for expected, recoverable failures — a method should return nil (or NO/false) and populate the NSError ** out-parameter together, consistently.
Always check the method's return value first; the NSError itself explains why, but the return value is the primary success/failure signal.
Use NSException only for exceptional, usually unrecoverable programmer or runtime failures — not as a substitute for NSError.
Do not use @try/@catch as ordinary control flow; most exceptions are not designed to be safely caught and recovered from in Objective-C.
Remember ARC's exception-safety caveats: catching and continuing after an exception in ordinary ARC code can leak or corrupt state.
Why This Distinction Matters in Production Apps
Real crash reports from shipping Objective-C apps are dominated by exactly the failure to respect this NSError-versus-NSException split: code that catches broad exceptions to paper over bugs instead of fixing them, and code that treats routine, expected failures (a network timeout, a missing file) as fatal because it never checked an NSError out-parameter. Teams that consistently use NSError for expected failures and reserve NSException for genuine programmer errors end up with codebases where a caught exception during development is treated as exactly what it should be — a bug report — rather than something routinely swallowed and ignored.
• (NSString *)loadConfig { if (!fileExists) { @throw [NSException exceptionWithName:@"MissingConfig" reason:@"..." userInfo:nil]; } return contents; } - (nullable NSString *)loadConfig:(NSError **)error { if (!fileExists) { if (error) { *error = [NSError errorWithDomain:... code:... userInfo:...]; } return nil; } return contents; }
A missing config file is an expected, routine condition any caller of this method should anticipate — modeling it as a thrown exception forces every caller into try/catch for something NSError already handles cleanly and idiomatically.
NSString *text = [self loadFileAtPath:path error:&error]; NSLog(@"%@", text.length); // used without checking for nil
NSString *text = [self loadFileAtPath:path error:&error]; if (!text) { NSLog(@"Failed: %@", error.localizedDescription); return; } NSLog(@"%@", text.length);
Using the returned value before checking whether it's nil skips the primary failure signal entirely; a failed call's return value must be checked before the value is trusted, regardless of whether error was also populated.
@try { riskyOperation(); } @catch (NSException *e) { /* swallow it silently and continue as if nothing happened */ }
// Fix the condition that would cause riskyOperation() to throw, or use an NSError-returning API for the expected failure case instead.
Silently swallowing an exception and continuing treats a signal that something is fundamentally wrong (a bug) as if it were a routine, ignorable event — and given ARC's exception-safety caveats, continuing afterward can also leave memory or object state in an inconsistent condition.
if (error) { // assume the call failed just because we passed &error in } if (error != nil) { // only meaningful to check error AFTER the call returns, and only alongside the return-value check
Whether error ends up populated is determined by whether the call fails, not by whether you passed a non-null pointer for it — the check belongs after the call, on the local variable's value, not on the fact that an error parameter exists at all.
Which kind of failure does NSError represent, according to the distinction this lesson insists on? A. An exceptional, usually unrecoverable programmer or runtime failure B. An expected, recoverable failure that calling code should routinely check for and handle C. Only failures that occur inside a @try block D. A failure that always terminates the app
B. An expected, recoverable failure that calling code should routinely check for and handle — NSError is designed for outcomes a correctly-written program anticipates as a normal possibility — a network timeout, a missing file — and reports through ordinary return values and an out-parameter, not through throwing.
In the idiomatic Objective-C pattern, what should a method that fails and would otherwise return an object do? A. Throw an NSException B. Return an empty object and leave the error parameter untouched C. Return nil and populate the NSError ** out-parameter D. Return NO regardless of the method's declared return type
C. Return nil and populate the NSError ** out-parameter — The standard idiom is to return nil to signal failure while also populating the caller's NSError out-parameter with details — and callers must check the return value, not just the error, as the primary failure signal.
Why does this lesson warn against using @try/@catch as ordinary control flow, the way some other languages use try/catch? A. Because @try and @catch are deprecated keywords B. Because Objective-C does not actually support catching exceptions C. Because most exceptions are not designed to be safely caught and recovered from, and ARC's interaction with exceptions has real memory-safety caveats D. Because @catch can only catch NSError, not NSException
C. Because most exceptions are not designed to be safely caught and recovered from, and ARC's interaction with exceptions has real memory-safety caveats — Objective-C exceptions are meant for exceptional, bug-indicating failures rather than routine conditions, and catching and continuing after one in ordinary ARC code isn't guaranteed to clean up local strong references correctly, which is a real cost beyond mere style.
Which of the following is the kind of failure the spec's distinction associates with NSException rather than NSError? A. A network request that timed out B. A file that does not exist at a given path C. An out-of-bounds array access caused by a bug D. A server responding with an HTTP 404 status code
C. An out-of-bounds array access caused by a bug — An out-of-bounds array access indicates a programmer error rather than a normal, expected operating condition — exactly the category NSException is meant to represent, unlike the other options, which are all routine, expected failure modes best modeled with NSError.
What does an NSError's domain (its NSErrorDomain) accomplish? A. It sets the network host the error came from B. It namespaces the error's numeric code so the same code number can mean different things across different subsystems without colliding C. It determines whether the error can be caught with @catch D. It automatically retries the failed operation
B. It namespaces the error's numeric code so the same code number can mean different things across different subsystems without colliding — Because error codes are just integers, the domain is what disambiguates them — code 4 in one domain and code 4 in a completely different domain can mean entirely unrelated things.
In a @try / @catch / @finally block, when does the @finally block's code run? A. Only if an exception was thrown and caught B. Only if no exception was thrown C. Unconditionally — whether or not an exception was thrown or caught D. Only if the @catch block itself throws a new exception
C. Unconditionally — whether or not an exception was thrown or caught — @finally is designed for guaranteed cleanup code and always executes after the @try/@catch, regardless of whether an exception occurred at all, which is what makes it useful for things like releasing a resource unconditionally.
Write an Objective-C method - (nullable NSDictionary *)parseConfigData:(NSData *)data error:(NSError **)error that uses NSJSONSerialization to decode data, returns nil and populates a custom NSError (domain MyAppConfigErrorDomain, a code you define) if the data isn't valid JSON or isn't a JSON object at the top level, and otherwise returns the decoded dictionary. Do not use @throw anywhere in this method.
static NSErrorDomain const MyAppConfigErrorDomain = @"com.example.MyApp.ConfigError"; typedef NS_ENUM(NSInteger, MyAppConfigErrorCode) { MyAppConfigErrorCodeNotAnObject = 1 }; - (nullable NSDictionary *)parseConfigData:(NSData *)data error:(NSError **)error { NSError *jsonError = nil; id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError]; if (jsonError) { if (error) { *error = jsonError; } return nil; } if (![object isKindOfClass:[NSDictionary class]]) { if (error) { *error = [NSError errorWithDomain:MyAppConfigErrorDomain code:MyAppConfigErrorCodeNotAnObject userInfo:@{ NSLocalizedDescriptionKey: @"Config JSON was not an object." }]; } return nil; } return (NSDictionary *)object; }
Follow the nil-return-plus-error-out-parameter idiom throughout: check NSJSONSerialization's own error first, then check isKindOfClass:[NSDictionary class], constructing and assigning your own NSError in the case that fails only that second check.