Annotating Objective-C Nullability: A Pragmatic Path to Safer Swift Interop
Objective-C nullability annotations and lightweight generics are compile-time only, but they reshape how your headers import into Swift — turning String! into String? and [Any] into [String]. Here is how to adopt them incrementally in a legacy codebase.
10 Jan 2026, 11:10 UTC

The problem: your Swift code is lying to you
If you have a mixed Swift/Objective-C project, open the generated Swift interface for one of your Objective-C headers. Chances are you will see a wall of implicitly unwrapped optionals: String!, [Any]!, and so on. That is Swift's way of saying "this Objective-C header told me nothing." Every call site becomes a coin flip between safety and a crash. The fix is not a rewrite — it is a set of annotations that Objective-C has supported since the Xcode 6.3 era: nullable, nonnull, and lightweight generics. The thesis of this post is simple: annotating headers is one of the cheapest bug-catching investments available in a legacy Objective-C codebase, and the payoff compounds the moment Swift enters the picture.
What nullability annotations actually do
Nullability annotations are compile-time only. Adding nonnull to a parameter tells Clang to warn when you pass something that might be nil. It does not insert runtime checks, and it does not stop a determined nil from flowing in from an unannotated code path. What it does do is two things: produce warnings like nil passed to a callee that requires a nonnull parameter in Objective-C, and change how your API imports into Swift. An unannotated NSString * becomes String! in Swift. A nullable becomes String?, and a nonnull becomes plain String. That distinction is the difference between a Swift caller who must handle nil and one who cannot.
Lightweight generics — NSArray<NSString *> * — work the same way. The type parameter is erased at runtime, exactly like Java generics. NSArray<NSString *> * and NSArray<NSNumber *> * are the same object at runtime. But the compiler will flag [stringArray addObject:@42] as a type mismatch, and Swift will import the array as [String] instead of [Any].
A worked example: auditing a data source header
Here is a small header before annotation:
// RMDDataSource.h (before)
@interface RMDDataSource : NSObject
- (instancetype)initWithTitle:(NSString *)title;
- (NSString *)displayNameForItemAtIndex:(NSUInteger)index;
- (NSArray *)itemsMatching:(NSString *)prefix;
@endIn Swift, this imports as:
init!(title: String!)
func displayName(forItemAt index: Int) -> String!
func items(matching prefix: String!) -> [Any]!Now the same header inside an audited region:
// RMDDataSource.h (after)
NS_ASSUME_NONNULL_BEGIN
@interface RMDDataSource : NSObject
- (instancetype)initWithTitle:(NSString *)title;
- (nullable NSString *)displayNameForItemAtIndex:(NSUInteger)index;
- (NSArray<NSString *> *)itemsMatching:(NSString *)prefix;
@end
NS_ASSUME_NONNULL_ENDNS_ASSUME_NONNULL_BEGIN makes every pointer in the region default to nonnull, so you only annotate the exceptions — here, displayNameForItemAtIndex: can legitimately return nil for an out-of-range index. In Swift, the signatures become:
init(title: String)
func displayName(forItemAt index: Int) -> String?
func items(matching prefix: String) -> [String]Notice what the compiler can now catch. If somewhere in the Objective-C implementation you write [[RMDDataSource alloc] initWithTitle:nil], Clang warns you about passing nil to a nonnull parameter. And in Swift, items(matching:) returning [String] means the caller does not need to unwrap or cast. The bug this catches most often is subtler: a Swift caller used to write dataSource.items(matching: "a") and get [Any]!, then force-unwrap and iterate. After annotation, the compiler forces the correct handling at both ends.
Adopting incrementally without a flag-day rewrite
The mistake to avoid is annotating everything nonnull in one pass. If you mark a method nonnull but the implementation can return nil, you have made the Swift API worse: Swift will import it as non-optional, and a nil at runtime becomes a crash inside Swift's implicitly unwrapped optional handling. The annotation must describe reality, not aspiration.
A strategy that works in practice:
- Start with public headers only. Wrap them in
NS_ASSUME_NONNULL_BEGIN/ENDand audit each method: can this parameter be nil? Can this method return nil? Mark the exceptionsnullable. Leave implementation files alone for now. - Build and read the warnings. Clang will flag places where nil flows into a
nonnullparameter. Fix the implementation or the annotation — whichever is wrong. - Add lightweight generics to collection types in headers:
NSArray<NSString *> *. This is where Swift's[Any]becomes[String]and saves the most unwrapping at call sites. - Enable
-Wnullability-completenessto get warnings for partially annotated headers, and treat warnings as errors only for new or touched code, not the whole target.
You can verify the effect without shipping anything: create a small annotated header, import it into Swift through your bridging header, and inspect the generated interface (in Xcode, navigate to the Swift file and use Jump to Generated Interface). Compare the Swift signatures before and after. If you want to confirm the compile-time-only nature of the check, write a unit test that passes nil to a nonnull parameter — the compiler warns, but the code still runs. That is the expected behavior, and it is why annotations are a complement to, not a replacement for, runtime validation.
The trade-offs
Annotations are compile-time only. They do nothing for unannotated third-party libraries, and they do nothing for legacy code paths you have not audited. A heterogeneous NSArray built before generics were added will still happily mix strings and numbers at runtime. The annotations also live in headers, so any consumer that bypasses the header (dynamic injection, runtime-unfriendly code) sees none of it. Finally, the exact diagnostics vary by Clang version — check which warnings your toolchain supports before assuming a specific flag exists.
Where to start this week
Pick your most Swift-facing Objective-C header, wrap it in NS_ASSUME_NONNULL_BEGIN/END, audit the handful of methods, add generics to public collections, and look at the generated Swift interface. If the Swift signatures read the way the API actually behaves, you have already caught the kind of bug that otherwise surfaces as a crash report. Then move to the next header. The cost is low, the feedback is immediate, and the Swift interop improvement is visible the moment you build.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.