BS
BleepingSwift
Published on
5 min read

> Nullability Annotations in Objective-C

Authors
  • avatar
    Name
    Mick MacCallum
    Twitter
    @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 nullable where 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.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.