Objective-C Nullability Annotations: Auditing Public Headers for Swift Interop
Wrap Objective-C public headers in NS_ASSUME_NONNULL regions and mark exceptions with nullable to get compile-time checks and correct Swift optional types.
22 Apr 2026, 00:30 UTC

The problem: implicit nil in Objective-C, explicit optionals in Swift
Every Objective-C object pointer can hold nil, but the language does not require you to say when that is allowed. Swift does. When Swift imports an Objective-C header, it must decide whether each pointer becomes an optional or a non-optional type. If your header is silent, the importer makes a guess, and the guess is often wrong. The practical fix is to add nullability annotations to your public headers so the compiler and Swift both see the same contract.
The useful takeaway: wrap your public Objective-C headers in an audited region with NS_ASSUME_NONNULL_BEGIN and NS_ASSUME_NONNULL_END, then explicitly mark the pointers that can be nil with nullable. This gives you compile-time warnings in Objective-C and correct optional types in Swift.
How the annotations work
Nullability annotations are compile-time metadata. They do not add runtime checks, and they do not prevent nil from arriving at a method. What they do is tell the compiler what you intend, so it can warn you when your Objective-C code violates that intent, and tell the Swift importer how to map the type.
Inside an audited region, any object pointer you leave unannotated is treated as nonnull. That is the opposite of the default Objective-C behavior, so you must review every pointer in the region rather than assuming the macro covers every case.
A worked header
Consider a session store that looks up a session by user ID and updates it:
NS_ASSUME_NONNULL_BEGIN
@interface SessionStore : NSObject
- (nullable Session *)sessionForUserID:(NSString *)userID;
- (void)updateSession:(Session *)session forUserID:(NSString *)userID;
@end
NS_ASSUME_NONNULL_END
The lookup method can return nil when no session exists, so it is marked nullable. The update method promises that both arguments are non-null. In Swift, the first method imports as returning Session?, and the second takes Session and String without optional wrapping.
Where to start
You do not need to annotate every private implementation file. Start with the smallest public surface that Swift code or external Objective-C code actually consumes:
- Initializers, especially ones that can fail and return
nil. - Collection accessors where a missing key or index should produce
nil. - Error-returning APIs, where the error out-parameter and the return value often have different nullability.
Consistency in headers matters more than exhaustive coverage. A partially audited header is still useful, but an inconsistently audited one creates a stricter contract than you intended.
Limits and common mistakes
Nonnull is not a runtime guarantee
A nonnull annotation does not insert a runtime assertion. nil can still arrive from older binaries that were compiled before the annotation existed, from dynamic dispatch, from parsed data, or from C APIs. Treat the annotation as documentation that the compiler checks, not as a validation layer.
Do not annotate to silence warnings
It is tempting to mark a parameter nonnull because a warning disappears. Audit the actual behavior first. If a method handles nil gracefully, or if an initializer can fail, the correct annotation is nullable. Marking a failure path nonnull lies to the compiler and to Swift callers.
Out-parameters and nested pointers
Inside an audited region, unannotated pointers are nonnull, but pointer levels are not always obvious. An out-parameter such as NSError ** is a pointer to a pointer. The outer pointer is usually non-null, but the inner NSError * it points to may be nil on success. Review each level separately.
Changing annotations changes the Swift API
Adding or removing nullability changes how Swift imports the API. A method that used to import as String? and becomes String can break Swift source compatibility even though Objective-C callers still compile. Treat annotation changes as API changes and version them accordingly.
Version sensitivity
Warning coverage and importer details vary by Xcode and Clang version. The annotations are well-established, but exact diagnostic behavior is not universal. Verify against your target SDK rather than assuming a specific warning will appear.
How to check the result
You can verify the contract in your own build with three practical checks.
1. Confirm the Swift import
Create a small header with an audited region and a nullable method, then import it into Swift through a bridging header. Use the generated interface to confirm that the nullable method imports as an optional and the unannotated method imports as non-optional. If the optionality is wrong, the annotation or the audited region is misplaced.
2. Test a deliberate nil
Compile with warnings enabled and deliberately pass nil to a nonnull parameter in a test file. Record whether your toolchain warns. Do not treat silence as proof of safety; some versions or configurations may not emit a diagnostic for every case.
3. Inspect the generated interface
Check the generated Swift interface for your target SDK. Importer behavior can differ between SDK versions, so the interface you see in your build is the contract your Swift code will actually use.
Summary
Nullability annotations are a small, mechanical change with a large effect on API clarity. Wrap public headers in audited regions, mark exceptions explicitly, and verify the Swift import in your own toolchain. The goal is not to eliminate nil at runtime, but to make the optionality of every pointer an explicit, checkable part of your API contract.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.