- Published on
- 6 min read
> Defensive Coding with NSError in Objective-C
- Authors

- Name
- Mick MacCallum
- @0x7fs
Objective-C settled on NSError over exceptions years ago, but simply using NSError ** parameters does not make your code robust. Defensive coding means anticipating how things fail and handling those failures gracefully. Here are patterns that will save you from mysterious crashes and make your error handling actually useful.
Always Check Return Values First
The NSError pointer is only valid when the operation fails. Never check the error before checking the return value:
// Wrong - error might contain garbage from previous calls
NSError *error;
NSData *data = [NSData dataWithContentsOfFile:path options:0 error:&error];
if (error) {
NSLog(@"Error: %@", error);
}
// Right - check success first
NSError *error;
NSData *data = [NSData dataWithContentsOfFile:path options:0 error:&error];
if (!data) {
NSLog(@"Error: %@", error);
return;
}
Apple's documentation explicitly states that the error parameter value is undefined when the method succeeds. Some methods might clear it, others might not. Relying on a nil error after success is asking for trouble.
The Optional Error Parameter
When you accept an NSError ** parameter, callers might pass NULL if they do not care about the error details. Always check before dereferencing:
- (BOOL)saveData:(NSData *)data toPath:(NSString *)path error:(NSError **)error {
if (!data) {
if (error) {
*error = [NSError errorWithDomain:@"MyAppDomain"
code:100
userInfo:@{NSLocalizedDescriptionKey: @"Data cannot be nil"}];
}
return NO;
}
NSError *writeError;
BOOL success = [data writeToFile:path options:NSDataWritingAtomic error:&writeError];
if (!success && error) {
*error = writeError;
}
return success;
}
The if (error) guard prevents dereferencing NULL when callers pass a nil pointer. This is a common source of crashes in code that copies error handling patterns without understanding them.
Creating Meaningful Errors
Generic errors make debugging painful. Include context that helps identify what went wrong:
static NSString *const MyAppErrorDomain = @"com.myapp.error";
typedef NS_ENUM(NSInteger, MyAppErrorCode) {
MyAppErrorCodeUnknown = 0,
MyAppErrorCodeNetworkUnavailable = 1,
MyAppErrorCodeInvalidResponse = 2,
MyAppErrorCodeFileMissing = 3,
};
- (NSError *)errorWithCode:(MyAppErrorCode)code
description:(NSString *)description
underlyingError:(NSError *)underlying {
NSMutableDictionary *userInfo = [NSMutableDictionary dictionary];
userInfo[NSLocalizedDescriptionKey] = description;
if (underlying) {
userInfo[NSUnderlyingErrorKey] = underlying;
}
return [NSError errorWithDomain:MyAppErrorDomain code:code userInfo:userInfo];
}
Using NSUnderlyingErrorKey creates error chains. When a high-level operation fails because of a low-level file system error, the chain preserves both layers:
- (BOOL)loadConfiguration:(NSError **)error {
NSError *readError;
NSData *data = [NSData dataWithContentsOfFile:self.configPath
options:0
error:&readError];
if (!data) {
if (error) {
*error = [self errorWithCode:MyAppErrorCodeFileMissing
description:@"Could not load configuration file"
underlyingError:readError];
}
return NO;
}
// Continue processing...
return YES;
}
When you log or display this error, both "Could not load configuration file" and the underlying "No such file or directory" information are available.
Validating Error Domains
When handling errors from external code, validate the domain before checking codes:
- (void)handleError:(NSError *)error {
if ([error.domain isEqualToString:NSURLErrorDomain]) {
switch (error.code) {
case NSURLErrorNotConnectedToInternet:
[self showOfflineMessage];
return;
case NSURLErrorTimedOut:
[self offerRetry];
return;
default:
break;
}
}
if ([error.domain isEqualToString:NSCocoaErrorDomain]) {
if (error.code == NSFileNoSuchFileError) {
[self handleMissingFile];
return;
}
}
// Generic fallback
[self showGenericError:error];
}
Error codes are only meaningful within their domain. Code 404 means different things in NSURLErrorDomain versus a custom domain.
Recoverable vs Fatal Errors
Not all errors deserve the same treatment. Distinguish between errors the user can recover from and those that indicate programmer mistakes:
- (void)processUserInput:(NSString *)input {
NSError *error;
id result = [self parseInput:input error:&error];
if (!result) {
if ([error.domain isEqualToString:MyAppErrorDomain]) {
// These are recoverable - show user feedback
switch (error.code) {
case MyAppErrorCodeInvalidFormat:
[self showFormatHelp];
return;
case MyAppErrorCodeValueOutOfRange:
[self highlightFieldWithError];
return;
}
}
// Unexpected error - log and show generic message
NSLog(@"Unexpected error parsing input: %@", error);
[self showGenericError];
return;
}
[self handleResult:result];
}
For programmer errors like invalid arguments, assertions are often more appropriate than NSError:
- (void)setName:(NSString *)name {
NSParameterAssert(name != nil);
NSParameterAssert(name.length > 0);
_name = [name copy];
}
Assertions crash during development, making bugs obvious. NSError is for runtime conditions that correct code can still encounter.
Propagating Errors Up the Call Stack
When an operation involves multiple steps, propagate errors without losing information:
- (BOOL)performMultiStepOperation:(NSError **)error {
NSError *stepError;
if (![self stepOne:&stepError]) {
if (error) *error = stepError;
return NO;
}
if (![self stepTwo:&stepError]) {
// Wrap with context about which step failed
if (error) {
*error = [self errorWithCode:MyAppErrorCodeOperationFailed
description:@"Step two failed"
underlyingError:stepError];
}
[self rollbackStepOne];
return NO;
}
if (![self stepThree:&stepError]) {
if (error) {
*error = [self errorWithCode:MyAppErrorCodeOperationFailed
description:@"Step three failed"
underlyingError:stepError];
}
[self rollbackStepTwo];
[self rollbackStepOne];
return NO;
}
return YES;
}
Each level adds context while preserving the original error. When you eventually log the error, you can traverse the NSUnderlyingErrorKey chain to see exactly what happened.
Thread Safety Considerations
NSError objects are immutable after creation, so they are safe to pass between threads. However, the NSError ** parameter itself requires care:
// Dangerous - error pointer might be invalid by the time block executes
- (void)loadDataAsync:(NSError **)error {
dispatch_async(self.queue, ^{
NSError *loadError;
// ... load data ...
if (loadError && error) {
*error = loadError; // Crash: error pointer may be stale
}
});
}
// Better - use a completion handler
- (void)loadDataWithCompletion:(void (^)(NSData *, NSError *))completion {
dispatch_async(self.queue, ^{
NSError *loadError;
NSData *data = [self loadSynchronously:&loadError];
dispatch_async(dispatch_get_main_queue(), ^{
completion(data, loadError);
});
});
}
The NSError ** pattern assumes synchronous execution. For async work, pass errors through completion handlers or delegate callbacks.
Logging Errors Effectively
When logging errors, include the full chain:
- (void)logError:(NSError *)error {
NSMutableString *log = [NSMutableString stringWithFormat:@"Error: %@ (%@ %ld)",
error.localizedDescription,
error.domain,
(long)error.code];
NSError *underlying = error.userInfo[NSUnderlyingErrorKey];
while (underlying) {
[log appendFormat:@"\n Caused by: %@ (%@ %ld)",
underlying.localizedDescription,
underlying.domain,
(long)underlying.code];
underlying = underlying.userInfo[NSUnderlyingErrorKey];
}
NSLog(@"%@", log);
}
This produces output that shows the complete failure path, not just the top-level message.
Defensive error handling is about more than checking for nil. It is about creating errors that help future debugging, propagating context through call stacks, and knowing when an error means "try again" versus "something is fundamentally broken." The patterns take a bit more code upfront but pay off the first time you need to debug a production issue.
// Continue_Learning
Why Objective-C Has try-catch But Nobody Uses It
Objective-C supports exception handling with @try/@catch, but the community settled on NSError pointers instead. Here's why.
Categories vs Class Extensions in Objective-C
Both let you add methods to classes, but categories and class extensions serve different purposes. Here's when to use each.
Namespacing in Objective-C - The Prefix Problem
Objective-C lacks namespaces, so the community invented conventions. Here's how prefixes and other patterns help avoid collisions in a flat symbol space.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.