Nullability Annotations: nullable, nonnull, and the Assume-Nonnull Regions
Understand Objective-C's nullability annotation system — nullable, nonnull, null_unspecified, and null_resettable — and how NS_ASSUME_NONNULL_BEGIN/END makes an entire header's default explicit, including why this system matters enormously for Swift interoperability.
For decades, Objective-C pointers carried no information about whether nil was an acceptable value. Any object pointer — a method parameter, a property, a return type — could silently be nil, and the compiler had no way to warn you if you passed nil somewhere the API author never expected it, or forgot to guard against a nil return value. Nullability annotations, introduced alongside Swift, close that gap. They let you state, directly in the type, whether a pointer may be nil, must never be nil, or is simply unaudited legacy code with no guarantee either way. The compiler then uses those annotations to catch a class of bugs — nil where non-nil was promised — before your app ever runs. This lesson covers the annotation vocabulary, the region macros that make annotating a whole header practical, and the specific, spec-called-out reason this all matters: how it shapes the way your Objective-C APIs appear when imported into Swift.
Why nullability needed its own annotations
In plain C and traditional Objective-C, a pointer type like NSString * says nothing about nil. A method declared as -(NSString *)nickname could return an actual string or nil, and the only way to know which was to read documentation, read the implementation, or find out the hard way at runtime when a nil crept somewhere it shouldn't have. Objective-C's permissiveness around sending messages to nil — a message send to nil simply returns nil or zero rather than crashing — hid many of these bugs long enough that they surfaced far from their true cause.
Nullability annotations turn that implicit, undocumented contract into an explicit, compiler-checked one. When you mark a parameter or return type nonnull, the compiler can warn at every call site where you pass a literal nil, or where you pass a variable the compiler can prove might be nil (for instance, one just checked against nil in a preceding if). None of this is runtime enforcement — nothing stops nil from getting through at runtime, and the annotation does not insert a runtime assertion — but the compile-time warnings catch a large fraction of nullability mistakes long before they become crashes.
The annotations attach directly to pointer types using a small vocabulary of qualifiers: nullable, nonnull, null_unspecified, and, for properties specifically, null_resettable. Each expresses a different contract, and choosing the right one for a given API is part of writing a well-documented, Swift-friendly Objective-C interface.
nullable, nonnull, and null_unspecified
nullable states that a pointer may legitimately be nil, and callers (or, for a property, code reading it) must be prepared to handle that. It is the correct annotation for anything with a genuine "absent" case — an optional configuration value, a delegate that hasn't been set yet, a lookup that might not find a match, or a property like a user's optional nickname, which the spec's own example uses: @property (nonatomic, copy, nullable) NSString *nickname; documents, right in the property declaration, that nickname may legitimately be nil.
nonnull is the opposite promise: this pointer must never be nil. For a parameter, it tells the compiler to warn if a caller passes a literal nil or a nil-checked variable. For a return type, it promises callers they never need a nil guard, because the method guarantees a real object. This is a serious contract — if your nonnull-annotated method actually returns nil under some edge case, you have violated your own declared API, and callers who trusted the annotation (skipping a nil check the compiler told them was unnecessary) can crash. The compiler warning is a courtesy to callers, not a guarantee it enforces on your implementation.
null_unspecified is the fallback for code that has not been audited for nullability at all — pointers about which the compiler simply makes no claim, exactly like ordinary unannotated Objective-C before nullability existed. This is the default state for any pointer inside a translation unit that hasn't been wrapped in an assume-nonnull region and hasn't been annotated individually. It exists mainly as a migration and interoperability category: legacy headers, or headers you haven't gotten around to auditing yet, fall here by default rather than being forced into an incorrect nonnull or nullable claim.
null_resettable: non-nil to read, nil to write
null_resettable is a more specialized annotation that applies to properties. It describes a property whose getter always returns a real, non-nil value — often because the getter computes or falls back to some sensible default when nothing has been explicitly set — but whose setter accepts nil as a legitimate way to reset the property back to that default.
A classic example is a view's tintColor. You can assign it nil to say "go back to inheriting the default tint," but reading tintColor never gives you back nil — it always resolves to some actual UIColor, whether that's the one you set or the inherited default. Declaring such a property nonnull would be wrong, because nil is a legal thing to assign; declaring it nullable would be equally wrong, because the getter never legitimately returns nil. null_resettable is the annotation built precisely for this asymmetric read/write contract, and the compiler and Swift importer both understand it specially.
In practice you'll see null_resettable far less often than nullable and nonnull, precisely because it applies only to this specific reset-to-default pattern. But when that pattern genuinely exists in your API, reaching for null_resettable rather than approximating it with nullable communicates the real contract instead of a slightly wrong one.
NS_ASSUME_NONNULL_BEGIN and NS_ASSUME_NONNULL_END
Annotating every single pointer in a header individually would be exhausting, and in most real APIs the overwhelming majority of parameters, properties, and return values are nonnull — nil is the exception, not the rule. NS_ASSUME_NONNULL_BEGIN and NS_ASSUME_NONNULL_END exist to flip the default: everything between the two macros is treated as nonnull unless you explicitly mark it nullable (or null_unspecified, on the rare occasion you need to opt back out of any claim at all).
The macros are placed as a matched pair bracketing a region of a header — typically the entire body of the header below the #import statements, wrapping every interface, property, and method declaration in the file. Inside that region you write plain, unannotated pointer types for the common nonnull case, and add nullable only where nil is genuinely possible, which is exactly the pattern the spec's nickname example demonstrates: inside an assume-nonnull region, only the exceptional nullable property needs an explicit annotation, while every other property and method in the same interface is nonnull by default without extra syntax.
Forgetting the END half of the pair is a real and easy mistake, because its effects are invisible until they leak: everything declared after a missing END keeps being treated as nonnull by default, including code in the same header that was never meant to make that promise, and possibly bleeding into headers imported afterward in the same compilation. Always add BEGIN and END together, and prefer wrapping the smallest region that makes sense — usually the whole header — so there is never a gap where you might forget the matching END.
Swift interoperability: the real payoff
The spec calls this out directly because it is the single biggest practical reason nullability annotations exist and matter: when Swift imports an Objective-C header, it uses nullability annotations to decide how to represent each pointer type in Swift's type system. A nonnull NSString * parameter or return value is imported as a plain, non-Optional String; a nullable one is imported as String?, a genuine Swift Optional; and null_unspecified is imported as an implicitly-unwrapped optional, String!, Swift's admission that it does not know whether nil is possible and is trusting the caller not to crash on it.
This matters enormously in mixed Objective-C/Swift codebases, which describes the overwhelming majority of real-world Apple platform projects. Every unaudited, un-annotated Objective-C header you import into Swift becomes a wall of implicitly-unwrapped optionals — types that compile without any nil-safety and defer the crash to runtime the moment a nil sneaks through, exactly the class of bug Swift's Optional system was designed to eliminate at compile time. Wrapping your headers in NS_ASSUME_NONNULL_BEGIN/END and annotating the true exceptions with nullable is, in a very real sense, how you extend Swift's compile-time nil-safety guarantees across the Objective-C/Swift boundary.
This is not the place to fully re-teach Swift interoperability — a later module in this course covers the mechanics of Objective-C and Swift interop in depth, including how initializers, generics, and error handling bridge across the boundary. The point to take from this lesson is narrower but important on its own: well-annotated nullability is not a cosmetic nicety for Objective-C-only code, it is foundational groundwork for any codebase that touches Swift at all, and it costs little more than wrapping your headers and marking the genuine exceptions.
The assume-nonnull region pattern
Everything between BEGIN and END defaults to nonnull. nickname is explicitly opted out with nullable because it may legitimately be absent. legalName and the initializer parameter need no annotation — they inherit the nonnull default.
NS_ASSUME_NONNULL_BEGIN
@interface Person : NSObject
@property (nonatomic, copy, nullable) NSString *nickname;
@property (nonatomic, copy) NSString *legalName; // implicitly nonnull
- (instancetype)initWithLegalName:(NSString *)legalName;
@end
NS_ASSUME_NONNULL_ENDCode Examples
This is the canonical minimal example: a copy property that is explicitly allowed to be nil. Any code reading self.nickname must be prepared for a nil result — perhaps by falling back to a display name — and any code assigning it may legitimately assign nil to clear it. Outside an assume-nonnull region this nullable is required to document the contract at all; inside one, it is the one annotation that overrides the otherwise-nonnull default.
userID and the initializer's userID parameter are nonnull by default — no annotation needed inside the region. nickname is explicitly nullable, matching the spec's example. accentColor is null_resettable: reading it always gives a real UIColor (falling back to a default when unset), but assigning nil resets it. cachedAvatarPathForSize: is annotated nullable directly on the return type because a lookup that might not find a cached avatar is exactly the kind of genuinely-absent case nullable exists for.
This is the concrete payoff the spec asks us to explain: because userID is nonnull (by default, inside the region) it becomes a plain non-Optional String in Swift, and because nickname is explicitly nullable it becomes a true String? Optional that Swift forces callers to unwrap or handle safely. Without any nullability annotation at all, both properties would import as the implicitly-unwrapped String!, silently disabling Swift's compile-time nil protection for this entire API.
@property (nonatomic, copy, nullable) NSString *nickname;NS_ASSUME_NONNULL_BEGIN
@interface UserProfile : NSObject
@property (nonatomic, copy) NSString *userID;
@property (nonatomic, copy, nullable) NSString *nickname;
@property (nonatomic, strong, null_resettable) UIColor *accentColor;
- (instancetype)initWithUserID:(NSString *)userID;
- (nullable NSString *)cachedAvatarPathForSize:(CGSize)size;
@end
NS_ASSUME_NONNULL_END// Objective-C, inside NS_ASSUME_NONNULL_BEGIN/END
@property (nonatomic, copy) NSString *userID;
@property (nonatomic, copy, nullable) NSString *nickname;
// Imported into Swift as:
// var userID: String { get set }
// var nickname: String? { get set }Auditing a header is a one-time cost with a permanent payoff
Real Objective-C codebases that predate nullability annotations often contain hundreds of headers with zero nullability information. Apple's own SDK headers were audited exactly this way — wrapped in NS_ASSUME_NONNULL_BEGIN/END with the genuine nullable and null_resettable exceptions marked — which is precisely why so much of Foundation and UIKit imports into Swift with clean, precise Optional types instead of a wall of implicitly-unwrapped bangs. Teams maintaining mixed Objective-C/Swift codebases typically treat 'audit this header for nullability' as a small, mechanical, high-value refactor: wrap the region, compile, read every warning about an unannotated pointer, and decide honestly whether nil is possible there. The result is fewer runtime nil crashes and a noticeably friendlier Swift-facing API surface, for a change that touches no logic at all.
• (nonnull NSString *)generateReportID { return nil; /* under some rare error path */ } - (nullable NSString *)generateReportID { return nil; /* documented as possible */ } nonnull is a promise to callers, not a runtime check the compiler inserts for you. If a nonnull-annotated method actually returns nil, callers who trusted the annotation and skipped their nil check can crash later, often far from this method, with a confusing stack trace. If nil is truly possible, annotate honestly as nullable instead of annotating aspirationally.
NS_ASSUME_NONNULL_BEGIN @interface Foo : NSObject ... declarations ... @end // forgot NS_ASSUME_NONNULL_END
NS_ASSUME_NONNULL_BEGIN @interface Foo : NSObject ... declarations ... @end NS_ASSUME_NONNULL_END
BEGIN and END must always be paired. Omitting END lets the nonnull default silently keep applying to everything declared afterward in the file — and potentially into subsequently imported headers — producing incorrect nullability claims on code the region was never meant to cover.
// treating null_unspecified as a safe default forever, never auditing legacy headers
// wrapping legacy headers in NS_ASSUME_NONNULL_BEGIN/END and auditing exceptions over time null_unspecified is a migration state, not a design goal. Leaving headers unaudited means every property and parameter in them imports into Swift as an implicitly-unwrapped optional, which defeats Swift's compile-time nil-safety for that entire surface area and pushes nullability bugs to runtime.
What does the nonnull annotation guarantee at runtime? A. The compiler inserts a runtime assertion that crashes immediately if nil is passed or returned B. Nothing at runtime — it is a compile-time contract that produces warnings, not a runtime check C. nil is automatically converted to an empty object D. The method is not compiled if any caller could theoretically pass nil
B. Nothing at runtime — it is a compile-time contract that produces warnings, not a runtime check — nonnull is purely a compile-time annotation. It enables compiler warnings at call sites and helps Swift import the type correctly, but it inserts no runtime check — if nil slips through anyway, nothing stops it.
In the spec's example, @property (nonatomic, copy, nullable) NSString *nickname;, what does nullable communicate? A. nickname must always contain a value B. nickname may legitimately be nil, and code must be prepared to handle that C. nickname is deprecated and should not be used D. nickname can only be set once
B. nickname may legitimately be nil, and code must be prepared to handle that — nullable documents that this property may legitimately hold nil — for example, a user who hasn't set a nickname — and that reading it must account for that possibility.
What is the effect of NS_ASSUME_NONNULL_BEGIN / NS_ASSUME_NONNULL_END? A. It makes every pointer in the region nullable by default B. It deletes all nullability information in the region C. It makes nonnull the default for the region, so only the nullable exceptions need explicit annotation D. It only affects Swift code, not Objective-C
C. It makes nonnull the default for the region, so only the nullable exceptions need explicit annotation — The macro pair flips the default inside the bracketed region to nonnull, so you only need to explicitly mark the true exceptions (usually with nullable), rather than annotating every single pointer.
A property whose getter always returns a real value (falling back to a default) but whose setter accepts nil to reset it should be annotated as: A. nonnull B. nullable C. null_unspecified D. null_resettable
D. null_resettable — null_resettable exists exactly for this asymmetric contract: non-nil when read, but nil is a valid value to assign in order to reset the property back to its default.
Why do nullability annotations matter for Swift interoperability? A. Swift ignores nullability annotations entirely and always imports String! B. They determine whether an Objective-C pointer imports into Swift as a non-Optional type, an Optional (T?), or an implicitly-unwrapped optional (T!) C. They are required for Swift code to call any Objective-C method at all D. They only affect performance, not the imported Swift type
B. They determine whether an Objective-C pointer imports into Swift as a non-Optional type, an Optional (T?), or an implicitly-unwrapped optional (T!) — nonnull imports as a plain Swift type, nullable imports as a true Optional (T?), and unaudited null_unspecified code imports as an implicitly-unwrapped optional (T!) — which is why well-annotated headers matter so much even in Swift-heavy codebases.
Take a Message class with properties senderID (never nil), body (never nil), and replyToMessageID (nil unless this message is a reply), plus a method - (NSString *)previewText that always returns some text, even if body is empty. Wrap the interface in NS_ASSUME_NONNULL_BEGIN/END, annotate only the genuinely nullable property, and state how each property would import into Swift.
NS_ASSUME_NONNULL_BEGIN @interface Message : NSObject @property (nonatomic, copy) NSString *senderID; @property (nonatomic, copy) NSString *body; @property (nonatomic, copy, nullable) NSString *replyToMessageID; - (NSString *)previewText; @end NS_ASSUME_NONNULL_END // Swift: senderID: String, body: String, replyToMessageID: String?, previewText() -> String
Only replyToMessageID needs nullable; everything else can rely on the assume-nonnull region's default.