- Published on
- 5 min read
> Nullability Annotations in Objective-C
- Authors

- Name
- Mick MacCallum
- @0x7fs
Before nullability annotations, every Objective-C pointer was implicitly "maybe nil." The compiler had no way to know if a method might return nil or if a parameter could accept it. Swift changed this by making optionality explicit, and Apple added nullability annotations to Objective-C so the two languages could communicate clearly about nil.
The Core Annotations
Objective-C provides four nullability annotations:
nonnull means the pointer should never be nil. If you pass nil, the compiler warns you:
- (void)setName:(nonnull NSString *)name;
- (nonnull NSString *)displayName;
nullable means the pointer might be nil:
- (void)setNickname:(nullable NSString *)nickname;
- (nullable NSString *)middleName;
null_unspecified is the default when you don't annotate anything. It means "we haven't said either way." This is what existing unannotated code implicitly uses.
null_resettable is for properties where the setter accepts nil but the getter never returns nil. Core Data uses this pattern where setting a property to nil resets it to a default:
@property (null_resettable, copy) NSString *title; // Set to nil = reset to default
NS_ASSUME_NONNULL Blocks
Annotating every pointer individually is tedious. Most pointers in well-designed APIs are nonnull, so Objective-C provides a way to flip the default:
NS_ASSUME_NONNULL_BEGIN
@interface UserManager : NSObject
@property (nonatomic, copy, readonly) NSString *currentUserID;
@property (nonatomic, strong, readonly) NSArray<User *> *allUsers;
- (User *)userWithID:(NSString *)identifier;
- (void)updateUser:(User *)user;
// Only annotate the exceptions
- (nullable User *)findUserByEmail:(NSString *)email;
@property (nonatomic, copy, nullable) NSString *lastSearchQuery;
@end
NS_ASSUME_NONNULL_END
Inside the block, everything is nonnull by default. You only need to mark the exceptions as nullable. This matches how most APIs are designed and reduces annotation noise.
Where to Place the Annotations
For parameters and return types, the annotation goes before the type:
- (nullable NSString *)processInput:(nonnull NSString *)input;
For properties, it goes in the attribute list:
@property (nonatomic, copy, nullable) NSString *optionalValue;
@property (nonatomic, strong, nonnull) NSArray *requiredItems;
For double pointers (like NSError **), you need two annotations. The common pattern for error parameters:
- (BOOL)saveToFile:(NSString *)path error:(NSError *_Nullable *_Nullable)error;
The first _Nullable refers to the NSError itself (which might be nil). The second refers to the pointer to the pointer (callers can pass NULL if they don't care about the error). This is the standard pattern that matches Apple's APIs.
The Underscore Variants
You'll see two syntaxes: nullable/nonnull and _Nullable/_Nonnull. They mean the same thing but apply differently:
The non-underscore versions work with Objective-C object pointers in method declarations:
- (nullable NSString *)getName;
- (void)setName:(nonnull NSString *)name;
The underscore versions work anywhere a type can appear, including C pointers and complex type expressions:
void processBuffer(char * _Nonnull buffer, size_t length);
NSString * _Nullable * _Nonnull getStringPointer(void);
For most Objective-C code, the non-underscore versions are cleaner. Use the underscore versions when the compiler complains or when you're working with C types.
Swift Bridging
The real payoff is Swift interop. Without nullability annotations:
// Objective-C
@interface LegacyAPI : NSObject
- (NSString *)fetchName;
- (void)updateName:(NSString *)name;
@end
Swift imports this with implicitly unwrapped optionals:
// Swift sees:
func fetchName() -> String!
func updateName(_ name: String!)
Those String! types are dangerous. They crash if nil slips through.
With proper annotations:
// Objective-C
NS_ASSUME_NONNULL_BEGIN
@interface ModernAPI : NSObject
- (NSString *)fetchName;
- (nullable NSString *)fetchNickname;
- (void)updateName:(NSString *)name;
@end
NS_ASSUME_NONNULL_END
Swift imports clean types:
// Swift sees:
func fetchName() -> String
func fetchNickname() -> String?
func updateName(_ name: String)
No force unwrapping, no crashes from unexpected nils. The Swift code reads naturally because it has the information it needs.
Combining With Lightweight Generics
Nullability pairs with lightweight generics for complete type information:
NS_ASSUME_NONNULL_BEGIN
@interface DataStore : NSObject
@property (nonatomic, copy, readonly) NSArray<NSString *> *allKeys;
- (nullable id)objectForKey:(NSString *)key;
- (NSArray<NSString *> *)keysMatchingPrefix:(NSString *)prefix;
@end
NS_ASSUME_NONNULL_END
Swift sees exactly what it expects: allKeys is [String], objectForKey: returns Any?, and keysMatchingPrefix: returns [String]. No casting required.
Auditing Existing Code
For existing codebases, adopt nullability incrementally. Start with your most-used public headers:
// Add the block around your interface
NS_ASSUME_NONNULL_BEGIN
@interface YourClass : NSObject
// ... existing declarations ...
@end
NS_ASSUME_NONNULL_END
The compiler will warn about any nullability inconsistencies. A method declared nonnull that returns nil? Warning. A nonnull property assigned from a nullable source without checking? Warning.
Fix the warnings by either:
- Adding
nullablewhere nil is actually valid - Adding nil checks where they're missing
- Reconsidering whether nil should be allowed at all
This audit often reveals latent bugs. A method that "should never return nil" but actually can under certain conditions? The annotation forces you to decide the correct behavior.
Runtime Behavior
Nullability annotations are compile-time hints. At runtime, nothing stops you from passing nil to a nonnull parameter:
- (void)setName:(nonnull NSString *)name {
_name = [name copy]; // If name is nil, _name becomes nil
}
// Somewhere else:
NSString *name = nil;
[user setName:name]; // Warning, but compiles and runs
The compiler warns, but the code runs. For true enforcement, add runtime checks:
- (void)setName:(nonnull NSString *)name {
NSParameterAssert(name != nil);
_name = [name copy];
}
The annotation tells the compiler and documentation readers what to expect. The assertion enforces it during development.
Common Patterns
Delegate properties are typically nullable (weak references might become nil):
@property (nonatomic, weak, nullable) id<MyDelegate> delegate;
Completion handlers that might not be provided:
- (void)fetchDataWithCompletion:(nullable void (^)(NSData *data, NSError *error))completion;
Factory methods that might fail:
+ (nullable instancetype)instanceWithData:(NSData *)data;
Initializers that can fail:
- (nullable instancetype)initWithPath:(NSString *)path;
Nullability annotations are a small addition to your headers that provide significant benefits: better compiler warnings, cleaner Swift bridging, and documentation that's enforced by the toolchain. If you're maintaining Objective-C code, especially code that Swift consumes, adding NS_ASSUME_NONNULL blocks to your headers is one of the highest-value improvements you can make.
// Continue_Learning
Lightweight Generics in Objective-C
Objective-C's lightweight generics let you specify element types for collections like NSArray<NSString *>. They catch type mismatches at compile time and improve Swift bridging.
Operator Precedence Gotchas When Moving Between Swift and Objective-C
A refactoring mishap with arithmetic expressions led me down the rabbit hole of operator precedence differences between Swift and Objective-C. Here's what to watch for.
Defensive Coding with NSError in Objective-C
NSError is the standard error handling mechanism in Objective-C, but using it defensively requires more than just passing a pointer. Learn patterns that prevent crashes and make debugging easier.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.