API Design for Swift: A Practical Checklist for Objective-C Headers That Import Cleanly
Learn to write Objective-C APIs — using NS_ASSUME_NONNULL, nullability, NS_ENUM/NS_OPTIONS, Swift-aware naming, NSError patterns, completion handlers, instancetype, lightweight generics, and availability annotations together — so they import into Swift as clean, safe, idiomatic Swift APIs rather than a defensive wall of implicitly-unwrapped optionals and raw integers.
This course has now introduced every individual piece of the Swift-interoperability story: nullability annotations, lightweight generics, NS_ENUM and NS_OPTIONS, NSError's out-parameter convention, instancetype, and the mechanics of how the Clang Importer bridges Objective-C into Swift in the first place. What none of those earlier lessons did, by design, was show all of it working together on one API. This lesson is that synthesis: a practical checklist for designing an Objective-C header with Swift import quality as a first-class concern from the start, followed by a full before-and-after worked example showing exactly how different the same functionality feels to use from Swift depending on whether that checklist was followed.
NS_ASSUME_NONNULL_BEGIN / END: making nonnull the default
The Nullability lesson covered NS_ASSUME_NONNULL_BEGIN and NS_ASSUME_NONNULL_END in depth: wrap a header's declarations in the matched pair, and every pointer inside defaults to nonnull unless explicitly marked nullable (or, rarely, null_unspecified). This belongs first on this checklist because it is the highest-leverage single change you can make to a header's Swift-import quality, and because everything else on this list assumes it's already in place — nullability is the foundation the rest of the system is built on top of.
In practice, wrapping a header costs almost nothing: place NS_ASSUME_NONNULL_BEGIN immediately after the #import statements and NS_ASSUME_NONNULL_END at the very end of the file, then read through the resulting compiler warnings (or just reason carefully) about which properties, parameters, and return values can genuinely be nil, marking only those with nullable. Everything else — which, in most well-designed APIs, is the majority of the surface area — needs no annotation at all, because it correctly inherits nonnull from the region.
The payoff, restated concretely for this lesson's purposes: a header with no nullability information at all imports every single pointer as an implicitly-unwrapped optional (T!) in Swift, silently disabling Swift's compile-time nil-safety for the entire API. A header wrapped in NS_ASSUME_NONNULL_BEGIN/END with honestly-marked exceptions imports as a precise mix of plain types and true Optionals (T?) — exactly matching what a Swift developer would have written by hand.
Nullability's concrete payoff, restated plainly
It's worth stating the payoff of full nullability coverage in the plainest possible terms, because it's easy to underrate: the difference between a header full of T! and a header full of T and T? is the difference between Swift code that compiles clean but can crash on any unguarded nil, and Swift code where the compiler itself refuses to compile a path that doesn't handle a genuine Optional. Implicitly-unwrapped optionals exist in Swift specifically as an escape hatch for exactly this situation — imported, unaudited Objective-C code — not as a type a well-designed Swift-facing API should be routinely producing on purpose.
A Swift developer consuming a fully nullability-audited API writes ordinary, idiomatic Swift: if let, guard let, optional chaining with ?., all working exactly as they would against a purely Swift-authored API. A Swift developer consuming an unaudited API either has to treat every single value defensively despite the type system claiming otherwise (defeating much of the point of using Swift at all), or trusts the implicit unwrap and accepts a real, if often rare, crash risk. The former costs the Objective-C author a modest one-time annotation effort; the latter costs every downstream Swift caller, forever, until someone eventually does the audit anyway.
NS_ENUM and NS_OPTIONS: a fuller worked example of each
The Swift Interoperability lesson introduced NS_ENUM and NS_OPTIONS as the macros that give a plain C-style set of integer constants real type identity, which the Clang Importer needs to bridge them into genuine Swift enums and OptionSets rather than raw, untyped integers. This lesson reinforces both with a fuller worked example, since seeing the resulting Swift shape side by side with the Objective-C declaration is what makes the payoff concrete.
A closed, mutually-exclusive set of cases belongs in NS_ENUM: typedef NS_ENUM(NSInteger, DownloadState) { DownloadStateIdle, DownloadStateInProgress, DownloadStateCompleted, DownloadStateFailed }; imports into Swift as a genuine enum DownloadState { case idle, inProgress, completed, failed } (with the shared DownloadState prefix conventionally stripped from each case name by the importer). Swift code can then switch over it exhaustively, with the compiler itself enforcing every case is handled — a guarantee a plain NSInteger constant could never provide.
A set of independent, combinable bit-flags belongs in NS_OPTIONS instead: typedef NS_OPTIONS(NSUInteger, EditingPermissions) { EditingPermissionNone = 0, EditingPermissionRead = 1 << 0, EditingPermissionWrite = 1 << 1, EditingPermissionDelete = 1 << 2 }; imports into Swift as a genuine OptionSet, EditingPermissions, letting Swift code combine values naturally as [.read, .write] or permissions.contains(.delete), with the same bitwise semantics as the Objective-C side but Swift's safer, more expressive OptionSet API layered on top. Reaching for NS_OPTIONS when values are meant to be OR'd together, and NS_ENUM when they're meant to be mutually exclusive, is the deciding factor between the two — using the wrong one produces a technically-working but conceptually misleading Swift API.
Naming: writing headers with Swift's eventual shape in mind
Objective-C convention favors long, descriptive, prefix-heavy names, because Objective-C has no module system of its own to prevent naming collisions across different libraries — hence NSString, UIView, CGRect, and countless framework-prefixed classes and functions across Apple's SDKs and third-party code. Swift, by contrast, has real module namespacing, so the Clang Importer automatically strips a redundant leading type prefix from method and property names where it can safely infer the type is already implied by context — an Objective-C method like -(void)setUserProfileImage:(UIImage *)image on a UserProfile class can import with the redundant UserProfile prefix dropped from the Swift-visible name where the importer judges it safe to do so.
The naming discipline worth internalizing when designing an Objective-C API that Swift will consume heavily is to write method names with their eventual Swift shape already in mind, particularly for initializers and factory methods. A pattern like initWithFrame:(CGRect)frame maps directly and idiomatically onto Swift's own init(frame:) initializer syntax — this is not a coincidence; it's exactly the kind of Objective-C naming Swift's own UIKit and AppKit classes use throughout, chosen specifically because keyword-labeled Objective-C method segments (withFrame:, forKey:, completionHandler:) translate naturally into Swift's own labeled-parameter calling convention.
The practical guidance is to keep using Objective-C's familiar keyword-per-argument naming style rather than abandoning it, but to choose those keywords the same way you'd choose Swift parameter labels — meaningful, specific, and free of redundant type information the surrounding context (the class name, the parameter's own declared type) already makes obvious, since that's exactly the kind of redundancy the importer is designed to strip away and the kind of naming a Swift developer would choose for the equivalent Swift-native API.
NSError patterns: the reliable path to automatic throws bridging
The Error Handling lesson covered the nil-return-plus-NSError-out-parameter idiom in depth: a method that can fail returns nil (for an object-returning method) or NO (for a BOOL-returning one) on failure, while populating a trailing NSError ** parameter with the details. This lesson reinforces why that specific convention matters so much for Swift: it is the single most reliable way to get the Clang Importer to bridge an Objective-C method automatically into a genuine throwing Swift function.
A method declared - (nullable NSData *)contentsOfFile:(NSString *)path error:(NSError )error; imports into Swift not as a function taking an NSErrorPointer and returning an Optional, but as func contents(ofFile path: String) throws -> Data — the NSError parameter disappears from the Swift signature entirely, replaced by Swift's own throws keyword, and Swift callers write ordinary try/catch (or try?, or try!) exactly as they would around a purely Swift throwing function.
This automatic bridging is precisely why following the nil/NO-plus-error-out-parameter convention consistently, exactly as the Error Handling lesson recommended, pays off doubly: it's the idiomatic Objective-C pattern on its own terms, and it's also the specific shape the importer looks for to produce clean, idiomatic throws-based Swift call sites rather than leaving Swift callers to manually manage an NSErrorPointer themselves.
Completion handlers, instancetype, and lightweight generics
Block-based completion-handler APIs — a method taking a trailing block parameter invoked asynchronously with a result or an error — are common throughout Objective-C, and writing them with Swift interop in mind means keeping the block's parameter list predictable and NSError-shaped: a result value (nullable when failure is possible) alongside an NSError * (nullable when success is possible), mirroring the synchronous nil/NSError-out-parameter convention closely enough that Swift's newer async/await bridging machinery can recognize the pattern and wrap it automatically into a Swift async function, sparing Swift callers from writing completion-handler closures by hand at all. Getting this exactly right is a deeper topic than this checklist can fully cover, but the shape to aim for — a completion block whose last two parameters read as (ResultType * _Nullable result, NSError * _Nullable error) — is the version of a completion handler API most likely to bridge cleanly.
instancetype belongs on this checklist too, carried forward from the much earlier Initialization Patterns lesson: declaring every initializer and factory method's return type as instancetype rather than a hardcoded class name matters specifically for Swift because it's exactly what lets Swift infer the correct concrete subclass as an initializer or factory method's return type, rather than whatever class the method happened to be written in. A Student subclassing Person and inheriting a factory method declared to return instancetype gets back a properly Student-typed result in Swift; the same method declared to return Person * would hand Swift callers a Person-typed value even when constructing a Student, discarding real type information at exactly the boundary where Swift callers most need it preserved.
Lightweight generics, covered in depth in the previous lesson, belong on this checklist as well, for the same reason already established there: any collection-returning property, parameter, or method benefits from a specific element type parameter — NSArray<NSString *> * rather than NSArray * — so Swift receives a properly typed [String] instead of a cast-requiring [Any]. Nothing new needs to be added here beyond folding it explicitly into this same checklist, alongside nullability, as one of the annotations that together make a collection-returning API import cleanly.
Availability annotations: catching version mismatches at compile time
The last item on this checklist addresses a different kind of problem than the others: not what shape a type takes when imported, but whether a given API is even safe to call on a particular deployment target at all. NS_AVAILABLE (and its more modern successor, API_AVAILABLE), along with the deprecation macros NS_DEPRECATED and __attribute__((deprecated(...))), let you declare directly in a header which platform versions an API is available on, and which versions it's been deprecated as of.
Without availability annotations, calling an API that only exists on a newer OS version than your app's deployment target compiles without complaint and simply crashes (or silently does nothing, depending on the exact failure mode) the first time it actually runs on an older device — a bug that surfaces only at runtime, on specific devices, often well after the code shipped. With NS_AVAILABLE or API_AVAILABLE correctly applied, both the Objective-C compiler and the Swift compiler catch the mismatch at compile time instead: calling code gets a compiler warning or error naming the exact version requirement, with a clear path to fixing it (an #available / @available check, an alternate code path, or raising the deployment target) long before the app ever ships.
This matters just as much for Swift callers as for Objective-C ones, and often more visibly — Swift's @available attribute and its if #available(...) runtime-check syntax are specifically designed to consume exactly this availability metadata from imported Objective-C headers, meaning a well-annotated Objective-C API gives Swift developers the same compile-time safety net around platform availability that a purely Swift-authored API would provide, while an unannotated one leaves them exposed to the same silent runtime failure Objective-C callers face.
Before and after: the same API, two very different Swift experiences
Putting the whole checklist together is best done by example, which the two code examples below walk through directly: a poorly-annotated header using none of these techniques, and the same functionality rewritten with every item on this checklist applied. The poorly-annotated version compiles and works from Objective-C exactly as intended, and that is precisely the trap — nothing about it looks broken from the Objective-C side, because Objective-C never needed any of this information to begin with. Its cost is paid entirely on the Swift side, in the form of implicitly-unwrapped optionals, untyped [Any] arrays, raw NSInteger constants with no enum identity, and initializers that may hand back the wrong subclass type.
The properly-annotated version costs a modest amount of extra Objective-C syntax — a handful of macros and a generic parameter or two — and changes nothing about the API's actual runtime behavior. What it changes entirely is the Swift-side experience: idiomatic Optional handling instead of implicit unwrapping, a real typed array instead of a wall of casts, a genuine switchable enum instead of a bag of integer constants, a throws function instead of manual NSErrorPointer plumbing, and a correctly subclass-typed initializer result. None of this required touching Swift at all — every bit of it lives entirely in how the Objective-C header was written.
The checklist, as a shape to apply to any header
Every checklist item appears at once: nonnull-by-default region, NS_ENUM, instancetype, lightweight generics, the NSError out-parameter, a completion-handler shape ready for async bridging, and an availability annotation. Nothing here changes runtime behavior -- it is entirely additional compile-time information for both the Objective-C and Swift compilers.
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, TaskPriority) {
TaskPriorityLow,
TaskPriorityNormal,
TaskPriorityHigh
};
@interface TaskManager : NSObject
- (instancetype)initWithName:(NSString *)name;
- (nullable NSArray<Task *> *)tasksWithPriority:(TaskPriority)priority
error:(NSError **)error;
- (void)fetchRemoteTasksWithCompletion:
(void (^)(NSArray<Task *> * _Nullable tasks, NSError * _Nullable error))completion
NS_AVAILABLE(10_15, 13_0);
@end
NS_ASSUME_NONNULL_ENDCode Examples
NS_ENUM gives this set of mutually-exclusive constants real type identity, letting the importer produce a genuine Swift enum with the shared DownloadState prefix stripped from each case. Swift's switch can then be exhaustive over it, something a plain NSInteger constant could never support. // Swift: // enum DownloadState { // case idle, inProgress, completed, failed // } // switch state { // case .idle: ... // case .inProgress: ... // case .completed: ... // case .failed: ... // } // compiler enforces every case is handled
NS_OPTIONS marks this as a combinable bit-flag set rather than a closed set of exclusive cases, so the importer produces a genuine Swift OptionSet with array-literal combination syntax and a contains(_:) check, rather than requiring Swift callers to manually manage bitwise OR and AND against raw integers. // Swift: // struct EditingPermissions: OptionSet { // static let read = EditingPermissions(rawValue: 1 << 0) // static let write = EditingPermissions(rawValue: 1 << 1) // static let delete = EditingPermissions(rawValue: 1 << 2) // } // let perms: EditingPermissions = [.read, .write] // perms.contains(.delete) // false
Nothing here is wrong Objective-C, and it compiles and runs correctly. But every checklist item is missing: no nullability annotations at all (everything imports as implicitly-unwrapped), the initializer returns id instead of instancetype (Swift can't infer a subclass's own type from it), tasksWithPriority: takes a raw NSInteger with no NS_ENUM identity, and its return type is an untyped NSArray * that imports as [Any].
Every change is additive Objective-C syntax with no runtime behavior difference, yet the Swift experience is transformed: a correctly non-Optional, correctly subclass-typed initializer result; a genuine TaskPriority enum instead of a raw integer; a real [Task] array instead of [Any]; and idiomatic try/catch instead of manual NSErrorPointer handling. This is the checklist's entire payoff made concrete in one comparison. // Swift, consuming the 'before' version: // let manager = TaskManager(name: "Sprint")! // id -> AnyObject-ish, awkward init // let tasks: [Any]? = manager.tasks(withPriority: 1, error: nil) // raw Int, [Any]?, casts everywhere // // Swift, consuming the 'after' version: // let manager = TaskManager(name: "Sprint") // instancetype -> correct, non-optional TaskManager // do { // let tasks: [Task] = try manager.tasks(withPriority: .high) // real enum, real throws, real [Task] // } catch { ... }
typedef NS_ENUM(NSInteger, DownloadState) {
DownloadStateIdle,
DownloadStateInProgress,
DownloadStateCompleted,
DownloadStateFailed
};typedef NS_OPTIONS(NSUInteger, EditingPermissions) {
EditingPermissionNone = 0,
EditingPermissionRead = 1 << 0,
EditingPermissionWrite = 1 << 1,
EditingPermissionDelete = 1 << 2
};@interface TaskManager : NSObject
- (id)initWithName:(NSString *)name;
- (NSArray *)tasksWithPriority:(NSInteger)priority
error:(NSError **)error;
@endNS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, TaskPriority) {
TaskPriorityLow,
TaskPriorityNormal,
TaskPriorityHigh
};
@interface TaskManager : NSObject
- (instancetype)initWithName:(NSString *)name;
- (nullable NSArray<Task *> *)tasksWithPriority:(TaskPriority)priority
error:(NSError **)error;
@end
NS_ASSUME_NONNULL_ENDWhy this checklist is a code-review standard, not just advice
Teams maintaining Objective-C frameworks consumed heavily by Swift -- whether an internal platform layer at a large iOS codebase or a widely-used open-source library -- routinely turn this exact checklist into an actual code-review gate for any new public header: is it wrapped in NS_ASSUME_NONNULL_BEGIN/END, do collection-returning members carry generic parameters, do closed constant sets use NS_ENUM and combinable ones use NS_OPTIONS, do initializers and factories return instancetype, do fallible methods follow the NSError out-parameter convention, and are newer APIs marked with the correct availability macro. None of these items are individually difficult, but skipping even one degrades the Swift-facing experience in a way that's easy to miss from the Objective-C side and expensive to retrofit later, once external Swift code has already been written against the degraded, unannotated shape.
// Leaving a header entirely unannotated because 'it works fine from Objective-C'
// Auditing for nullability, generics, NS_ENUM/NS_OPTIONS, instancetype, and availability specifically because Swift callers will consume this header
An unannotated header is not broken Objective-C -- it works correctly on its own terms, which is exactly why teams underestimate the cost. The cost is paid entirely by Swift callers, in implicitly-unwrapped optionals, untyped collections, and raw integer constants with no enum identity.
typedef NS_OPTIONS(NSUInteger, SortOrder) { SortOrderAscending, SortOrderDescending };
typedef NS_ENUM(NSInteger, SortOrder) { SortOrderAscending, SortOrderDescending };
SortOrder's cases are mutually exclusive -- a sort is either ascending or descending, never both -- so this belongs in NS_ENUM, not NS_OPTIONS. Using NS_OPTIONS here produces a Swift OptionSet where combining .ascending and .descending together is nonsensically allowed by the type system.
• (Person *)personWithName:(NSString *)name; // hardcoded return type on a factory method - (instancetype)personWithName:(NSString *)name; // instancetype infers the calling class correctly
A factory or init method that returns a hardcoded class name rather than instancetype gives Swift the wrong static type whenever the method is inherited and called through a subclass -- Swift will see the declared class, not the actual constructed subclass, discarding real type information at exactly the boundary Swift callers rely on most.
// Shipping a newer-OS-only API with no NS_AVAILABLE / API_AVAILABLE annotation, discovered only when it crashes on an older device in production
// Annotating with NS_AVAILABLE / API_AVAILABLE so both Objective-C and Swift callers get a compile-time warning or error on deployment targets that don't support it
Without availability annotations, a version mismatch is a runtime failure discovered on specific devices, often after shipping. With them, both compilers catch the mismatch immediately, at the call site, before the code ever runs on real hardware.
What is the primary reason NS_ASSUME_NONNULL_BEGIN/END is listed first on this checklist? A. It is the only annotation the Swift compiler actually reads B. It is the highest-leverage single change for Swift-import quality, and the rest of the checklist assumes a nullability-audited baseline C. It replaces the need for NS_ENUM and NS_OPTIONS D. It is required before instancetype can be used
B. It is the highest-leverage single change for Swift-import quality, and the rest of the checklist assumes a nullability-audited baseline — Wrapping a header in the assume-nonnull region, with honest nullable exceptions, is a small change with an outsized effect on how cleanly the entire header's types import into Swift, which is why it anchors the rest of the checklist.
A set of constants representing independently combinable bit-flags, like read/write/delete permissions, should be declared with: A. NS_ENUM B. NS_OPTIONS C. NS_ASSUME_NONNULL D. instancetype
B. NS_OPTIONS — NS_OPTIONS is for combinable bit-flag sets, importing into Swift as a genuine OptionSet. NS_ENUM is for closed, mutually-exclusive cases, which would be the wrong choice for values meant to be OR'd together.
Why does following the NSError-out-parameter convention (nil/NO return plus NSError **) matter specifically for Swift? A. It is purely an Objective-C style preference with no effect on Swift B. It is the specific shape the Clang Importer looks for to bridge the method automatically into a genuine throwing Swift function C. It prevents the method from being called from Swift at all D. It is only relevant to completion-handler-based APIs, not synchronous ones
B. It is the specific shape the Clang Importer looks for to bridge the method automatically into a genuine throwing Swift function — A method following this convention is recognized by the importer and bridged into a Swift function using throws, with the NSError ** parameter dropped from the Swift signature entirely, letting Swift callers use ordinary try/catch.
Why does instancetype specifically matter for Swift, beyond general Objective-C correctness? A. It is required for any method to be visible to Swift at all B. It lets Swift correctly infer the concrete subclass an initializer or factory method actually returns, rather than the declared class C. It automatically makes a method throw in Swift D. It is only relevant to class methods, never instance methods
B. It lets Swift correctly infer the concrete subclass an initializer or factory method actually returns, rather than the declared class — A hardcoded return type discards subclass information at the exact boundary point Swift needs it most; instancetype preserves it, so a subclass's inherited initializer or factory method correctly returns that subclass's type in Swift.
What is the risk of shipping an API without an availability annotation like NS_AVAILABLE or API_AVAILABLE? A. The API simply will not compile on any platform B. Calling it on a deployment target that doesn't support it compiles cleanly but can fail at runtime on real devices, instead of being caught at compile time C. It has no effect at all -- availability annotations are purely documentation D. It only affects Objective-C callers, never Swift callers
B. Calling it on a deployment target that doesn't support it compiles cleanly but can fail at runtime on real devices, instead of being caught at compile time — Without availability metadata, a version mismatch surfaces only at runtime, often on specific older devices in production. With it, both the Objective-C and Swift compilers catch the mismatch at the call site, before the code ships.
In the before-and-after worked example, what changed about the API's actual runtime behavior between the poorly-annotated and fully-annotated versions? A. The fully-annotated version runs faster B. Nothing -- every change was additional compile-time annotation, with no difference in what the code actually does at runtime C. The fully-annotated version supports more task priorities D. The poorly-annotated version could not be compiled at all
B. Nothing -- every change was additional compile-time annotation, with no difference in what the code actually does at runtime — Every checklist item -- nullability, NS_ENUM, instancetype, generics, the NSError convention, availability -- is compile-time-only annotation. The runtime behavior of both versions is identical; only the type information available to the Objective-C and Swift compilers differs.
Rewrite this header applying the full checklist: @interface ImageCache : NSObject\n- (id)init;\n- (UIImage *)imageForKey:(NSString *)key;\n- (NSArray *)allKeys;\n- (void)fetchImageForURL:(NSString *)url completion:(void (^)(UIImage *image, NSError *error))completion;\n@end -- add nullability, instancetype, a lightweight generic on allKeys, and state which parts of the completion handler should be nullable.
NS_ASSUME_NONNULL_BEGIN @interface ImageCache : NSObject - (instancetype)init; - (nullable UIImage *)imageForKey:(NSString *)key; - (NSArray<NSString *> *)allKeys; - (void)fetchImageForURL:(NSString *)url completion:(void (^)(UIImage * _Nullable image, NSError * _Nullable error))completion; @end NS_ASSUME_NONNULL_END // init -> instancetype; imageForKey: is nullable since a missing key returns nil; allKeys is NSArray<NSString *> * (imports as [String]) since it's never nil itself; the completion block's image and error are each nullable since exactly one is expected to be non-nil per call.
imageForKey: can legitimately return nil for a missing key; the completion block's image and error are the two nullable-in-opposite-cases parameters.