Swift Interoperability: How Objective-C and Swift Talk to Each Other
Explain, in depth, how Objective-C and Swift code coexist and call into one another within the same project — the generated Objective-C interface, the @objc attribute and its requirements, how nullability and lightweight generics translate across the boundary, how NS_ENUM/NS_OPTIONS and the NSError convention import automatically into idiomatic Swift, and where the boundary simply cannot be crossed — so that working in a mixed-language Apple codebase stops feeling like guesswork.
Almost no serious Apple-platform codebase today is purely one language. Apps that began life in Objective-C years ago have been steadily growing new Swift, while brand-new Swift codebases still routinely depend on mature Objective-C libraries, either Apple's own frameworks or a team's existing internal code. This lesson is flagged mandatory for exactly that reason: interoperability is not a niche advanced topic, it is the everyday reality of Objective-C in 2026. The two languages are not merged into one — they remain genuinely different languages, with different type systems, different dispatch models, and different feature sets — but Apple's toolchain builds a translation layer between them that is good enough that, most of the time, you can call Objective-C from Swift and Swift from Objective-C as if they were the same language. Understanding how that translation layer works, and just as importantly where it breaks down, is what separates fumbling through mixed-language build errors from confidently designing APIs that are meant to be used from the other side. Everything in this lesson builds on ideas from earlier modules — the object-messaging runtime, inheritance and NSObject, nullability annotations, and lightweight generics — so this lesson is also, in a real sense, the payoff for all of that earlier material: it is where those Objective-C-specific features prove their value by producing a genuinely idiomatic Swift experience on the other side of the bridge.
Two Directions, One Mechanism: The Clang Importer and the Generated Objective-C Interface
Interoperability between Objective-C and Swift runs in two directions, and it is worth being precise about which direction is which before anything else, because the mechanisms involved are almost entirely different. The first direction is Objective-C → Swift: exposing existing Objective-C APIs so that Swift code can call them. This is by far the more common direction in practice, because most mixed codebases are Swift-first projects that still depend on legacy Objective-C classes, or Swift apps consuming Objective-C-based system frameworks and third-party libraries. The second direction is Swift → Objective-C: exposing Swift declarations so that older Objective-C code, elsewhere in the same target, can call into newer Swift components — common during an incremental migration where a team is rewriting pieces of an app in Swift but the surrounding Objective-C code has not yet been touched.
Both directions are driven by the same underlying piece of technology inside the Swift compiler toolchain: the Clang Importer. Clang is the C/Objective-C compiler front end; the Clang Importer is the part of the Swift compiler that reads Objective-C headers using Clang's own parser and translates the declarations it finds into Swift-equivalent declarations, applying a long list of systematic transformation rules along the way (renaming, nullability inference, generics translation, error-handling conversion, and more — each covered later in this lesson). It is important to understand that this translation happens automatically and mechanically, from the compiled understanding of your Objective-C headers, not from some separate hand-maintained description of your API — which is exactly why writing clean, well-annotated Objective-C headers has a direct, visible payoff in how pleasant the resulting Swift API feels to use.
The reverse direction, Swift → Objective-C, does not use the Clang Importer at all, because Objective-C has no equivalent tool for parsing Swift. Instead, when a Swift target contains declarations marked (explicitly or implicitly) as visible to Objective-C, the Swift compiler itself generates an ordinary Objective-C header file describing them, and Objective-C code simply imports that generated header like any other. So while both directions achieve the same practical outcome — cross-language visibility — Objective-C → Swift is a read-and-translate process performed by the Swift compiler's Clang Importer, while Swift → Objective-C is a generate-a-header process performed by the Swift compiler itself, with Objective-C then consuming that generated header the ordinary way it consumes any header.
That generated header, concretely, is named ProductModuleName-Swift.h — substituting your actual module name, so for a target named MyApp it would be MyApp-Swift.h. It is a real, readable Objective-C header, produced fresh by Xcode's build system on every relevant build, containing Objective-C-flavored declarations for every Swift class, method, property, and protocol that has been marked visible to Objective-C (via @objc, discussed next). Any Objective-C .m file in the same target imports it with an ordinary #import "MyApp-Swift.h" and then calls the Swift code exactly as if it had been Objective-C all along — instantiating Swift classes with alloc/init, calling Swift methods with bracket message syntax, and so on.
It is worth opening this generated header at least once in a real project, because it demystifies the whole mechanism: rather than some opaque bridge, it is just an ordinary .h file with @interface blocks, method declarations, and property declarations, mechanically produced from your Swift source. Reading it also tells you, very concretely, exactly what a given piece of Swift code looks like from the Objective-C side — which is invaluable when something that compiles fine in Swift refuses to compile, or behaves oddly, when called from Objective-C, since the generated header is the ground truth for what Objective-C actually sees.
This generated interface only contains what was explicitly (or implicitly, for members of an already-@objc-marked class) exposed to Objective-C. Plain Swift features that have no Objective-C equivalent — a struct, a free function that isn't a method, a generic function, a Swift-only protocol — simply do not appear in it at all, because there is no way to represent them in Objective-C's type system. That silent omission is the first hint of a theme that runs through this entire lesson: the Objective-C/Swift boundary is not symmetric, and a meaningful subset of each language's features has no representation on the other side.
• ProductModuleName-Swift.h is auto-generated by the build, exposing @objc-visible Swift declarations as ordinary Objective-C. • Objective-C files import it with #import "ProductModuleName-Swift.h" and then use the Swift code with normal Objective-C syntax. • It is a real, inspectable header — reading it is the most reliable way to see exactly what Objective-C sees of your Swift code. • Swift-only features (structs, generics, free functions, Swift-only protocols) are simply absent from it — they have no Objective-C form.
@objc: Marking a Swift Declaration Visible to the Runtime
The @objc attribute is how a Swift declaration opts in to being visible to the Objective-C runtime, and therefore to appearing in the generated -Swift.h interface. Written on a class, method, property, or initializer, @objc tells the compiler: generate the Objective-C runtime metadata for this declaration, and make it callable through Objective-C message sending. A class that subclasses NSObject and is marked @objc — or, in many common cases, is implicitly inferred to need @objc because it overrides an @objc member or conforms to an @objc protocol — gets a full Objective-C-compatible class structure the runtime can dispatch messages to, exactly like a class written directly in Objective-C.
Not everything in Swift can be given this treatment, and understanding why matters more than memorizing the list. The Objective-C runtime's whole model of a 'type' is built around named classes, C-compatible primitive types, and Objective-C objects addressed by pointer — it has no concept of a Swift generic type parameter, no concept of a Swift tuple, and no concept of a Swift value type (struct or enum without an Objective-C-compatible raw representation) as a first-class object it can send messages to. Concretely: a generic Swift method (func process<T>(_ item: T)) cannot be marked @objc, because the Objective-C runtime has no way to represent 'whatever T turns out to be' — Objective-C method signatures are fixed, concrete types. A method returning a tuple (Int, String) cannot be marked @objc either, since Objective-C has no tuple type. And a plain Swift struct cannot itself be exposed to Objective-C, because Objective-C objects are always reference types manipulated through the runtime's class/instance machinery, while a Swift struct is a value type with none of that machinery.
This is not an arbitrary restriction — it follows directly from what the Objective-C runtime fundamentally is: a system for sending messages to objects that have an isa pointer identifying their class, looked up dynamically at call time. @objc is, in effect, a promise from the Swift compiler to the runtime: 'this declaration can be described in terms you understand.' When a Swift feature has no honest translation into that vocabulary, the compiler refuses to let you mark it @objc, and you get a compile error rather than a silently broken translation — which is exactly the right failure mode for a language boundary like this.
• @objc marks a Swift declaration as visible to the Objective-C runtime and eligible to appear in the generated interface. • It can be inferred implicitly (e.g. overriding an @objc member, conforming to an @objc protocol) as well as written explicitly. • Generics, tuples, and plain (non-Objective-C-compatible) value types generally cannot be marked @objc — the runtime has no vocabulary for them. • A refused @objc marking is a compile-time error, not a silent, broken translation — a deliberate and useful failure mode.
NSObject Inheritance and Why It's Required
For a Swift class to be usable from Objective-C, it must inherit from NSObject, either directly or through some ancestor class that ultimately does. This is not an arbitrary rule bolted on for convenience — it follows directly from material covered in the earlier Inheritance and Runtime lessons: Objective-C's entire object model depends on every instance carrying an isa pointer identifying its class, and every class participating in the runtime's class-registration, method-resolution, and message-dispatch machinery, all of which NSObject (and the runtime support underneath it) provides. A Swift class that does not descend from NSObject simply does not have that machinery — it's a pure Swift object, managed entirely by Swift's own runtime model, with no isa pointer the Objective-C message-sending machinery could ever find or interpret.
This is why a plain Swift class (class Widget { }, no inheritance from NSObject) cannot be marked @objc and used from Objective-C, even if every individual member would otherwise be simple enough to expose — the class itself lacks the foundational runtime identity Objective-C's messaging system requires to address it at all. Subclassing NSObject gives the class that identity: it becomes a real Objective-C-runtime class, with an isa pointer, a method-dispatch table the runtime can walk, and eligibility to be the receiver of an Objective-C message send exactly as described in the earlier Sending Messages and Inheritance lessons.
In practice, this means the decision to inherit from NSObject in a Swift class is really a decision about whether that class needs to participate in the Objective-C world at all — being usable from Objective-C code, working with APIs that expect NSObject-conforming types (like being stored in an NSArray, or being the target of Objective-C-style key-value observing), or simply gaining NSObject's baseline behaviors like isEqual: and description. A pure Swift class that never needs any of that is often better left as a plain Swift class — inheriting from NSObject is opt-in overhead, not a default you should reach for out of habit.
• A Swift class must inherit from NSObject (directly or indirectly) to be visible to Objective-C at all. • This follows from the runtime's isa-pointer / message-dispatch model, covered earlier: without it, there's no runtime class identity to address. • A plain Swift class with no NSObject ancestor cannot be marked @objc, regardless of how simple its members are. • Inheriting from NSObject is a deliberate opt-in into the Objective-C runtime, not something to do by default.
Dynamic Dispatch: A Real Behavioral and Performance Difference
Swift's normal method dispatch is largely static or vtable-based: for most Swift code, the compiler can determine at compile time (or through a simple virtual-table lookup) exactly which function implementation a call resolves to, which is fast and enables aggressive compiler optimization like inlining. Objective-C's dispatch model is fundamentally different, as covered in the earlier Sending Messages lesson: every call is a dynamic message send, resolved at runtime by looking up the receiver's actual class and walking its method-dispatch machinery, which is slower per call but enables Objective-C's dynamic features — method swizzling, KVO, and message forwarding among them.
When a Swift member is marked @objc, it is dispatched dynamically, through the Objective-C message-sending machinery, rather than through Swift's normal static or vtable dispatch — even when that member is called from other Swift code in the same file. This is a genuine, measurable difference, not just an implementation detail: an @objc method call pays the cost of Objective-C-style dynamic dispatch, and in exchange gains the ability to participate in Objective-C's dynamic runtime features (being observed via KVO, being swizzled, being the target of Objective-C's respondsToSelector:/performSelector: machinery). For performance-sensitive Swift-only code, this is a real reason to avoid @objc on members that don't actually need Objective-C or runtime-dynamic visibility.
There is also a related but distinct keyword, dynamic, which can be combined with @objc to explicitly force full dynamic (message-send) dispatch even in situations where the compiler might otherwise be tempted to devirtualize or optimize the call — this matters specifically for features like KVO that rely on the runtime being able to intercept and redirect a method call at runtime (for example, by swizzling in a different isa for an observed object, as covered in the KVO material). The practical rule of thumb: reach for @objc when Objective-C or the Objective-C runtime genuinely needs to see a declaration, understand that doing so trades away some of Swift's static-dispatch performance advantage, and don't sprinkle it across a codebase 'just in case.'
• Swift's default dispatch is static or vtable-based; Objective-C's is fully dynamic, resolved at runtime via message sending. • An @objc Swift member is dispatched dynamically, through Objective-C's messaging machinery — even when called from other Swift code. • This trades some raw call performance for participation in Objective-C's dynamic features (KVO, swizzling, message forwarding). • The dynamic keyword can be paired with @objc to force message-send dispatch explicitly, which some runtime-dependent features require.
Nullability Annotations: The Payoff for Well-Annotated Headers
This is the direct payoff of the earlier dedicated Nullability lesson. Objective-C's pointer types are not inherently nullable-or-not the way Swift's Optional is explicit about — a plain NSString * could, in principle, hold nil or a real string, and nothing in the type itself tells you which to expect. The nullable and nonnull annotations (and the NS_ASSUME_NONNULL_BEGIN / NS_ASSUME_NONNULL_END region macros) covered in that earlier lesson exist specifically to make that distinction explicit in your Objective-C headers — and the Clang Importer reads those annotations directly to decide what Swift type to generate.
Without nullability annotations, the Clang Importer has no reliable information about whether a given Objective-C pointer can be nil, so it falls back to importing it as an implicitly-unwrapped optional (String!) — a type that lets Swift code use the value without unwrapping it, but crashes at runtime if it actually turns out to be nil. That is exactly the kind of unsafe, runtime-crash-prone situation Swift's optional system was designed to prevent, and it is the direct cost of leaving Objective-C headers unannotated.
With proper nullability annotations, the same import produces a real, safe Swift type: a Objective-C parameter or property marked nullable imports as a genuine Swift Optional (String?), which the Swift compiler forces callers to unwrap or handle safely, while one marked nonnull imports as a plain, non-optional Swift type (String), with no optional-handling ceremony required at all because Swift trusts the annotation that nil is simply not possible there. The practical conclusion is direct: annotating Objective-C headers with nullability isn't just good Objective-C style, it is precisely what determines whether the Swift code consuming that header ends up with an honestly-typed, safe API or a minefield of implicitly-unwrapped optionals waiting to crash.
• Unannotated Objective-C pointers import into Swift as implicitly-unwrapped optionals (T!) — usable without unwrapping, but crash-prone if nil. • nullable imports as a real Swift Optional (T?), forcing safe handling at compile time. • nonnull (and NS_ASSUME_NONNULL_BEGIN/END regions) imports as a plain non-optional Swift type (T), with no optional handling needed. • Nullability annotation quality in your Objective-C headers directly determines Swift API safety — this is the concrete payoff of the earlier Nullability lesson.
Lightweight Generics: Typed Collections Across the Boundary
This is the second direct payoff from an earlier lesson — Lightweight Generics. As covered there, NSArray<NSString *> * lets you declare, in Objective-C, that an array is meant to hold only NSString instances, with the compiler checking that intent (even though, at runtime, it's still erased to a plain, untyped NSArray). That annotation is not just a nicety for Objective-C-side compile-time checking — the Clang Importer reads it too, and it directly determines the Swift type the collection imports as.
An untyped NSArray * property or parameter — no lightweight generic annotation — imports into Swift as [Any], an array whose element type Swift knows nothing about, forcing Swift code to cast every element before doing anything useful with it. An NSArray<NSString *> * property, by contrast, imports as [String] — a properly, statically typed Swift array of strings, usable immediately with all of Swift's string-aware array APIs, no casting required. The same pattern holds for NSDictionary<KeyType, ValueType> *, importing as a properly typed Swift [Key: Value] dictionary, and NSSet<ObjectType> * importing as a typed Set<Element>.
As with nullability, the lesson generalizes: features that look, from the Objective-C side, like optional compile-time-checking conveniences turn out, from the Swift side, to be the difference between an untyped, cast-everywhere API and a properly typed, idiomatic one. Writing NSArray<NSString *> * instead of a bare NSArray * costs nothing extra to write and produces a meaningfully better Swift-facing API for free.
• A bare, untyped NSArray * (etc.) imports into Swift as [Any] — forcing casts before use. • NSArray<NSString *> * imports as a properly typed Swift [String], usable immediately with no casting. • The same applies to NSDictionary<K, V> * → [K: V] and NSSet<T> * → Set<T>. • Lightweight generics in Objective-C headers are what make Swift-imported collections feel like real, typed Swift collections.
NS_ENUM and NS_OPTIONS: Clean Enum and OptionSet Import
Plain C enums are famously loose: they're really just named integer constants, with no strong typing and no clean way for the Clang Importer to know whether a value is meant to be a single choice among mutually exclusive options, or a bitmask where several options can be combined. Objective-C addresses this with two macros, NS_ENUM and NS_OPTIONS, both of which produce ordinary, fixed-underlying-type C enums at the Objective-C level, but carry metadata the Clang Importer uses to generate a properly idiomatic Swift type.
NS_ENUM is for a closed set of mutually exclusive values — exactly one of which applies at a time. A worked example: typedef NS_ENUM(NSInteger, EHDownloadState) { EHDownloadStateIdle, EHDownloadStateInProgress, EHDownloadStateCompleted, EHDownloadStateFailed }; declares an Objective-C enum with four named states. The Clang Importer turns this into a genuine Swift enum EHDownloadState { case idle, case inProgress, case completed, case failed } (stripping the redundant EHDownloadState prefix from each case name, per the naming rules discussed below), usable with Swift's exhaustive switch statements and all the safety that implies.
NS_OPTIONS is for a bitmask where multiple values can be combined with bitwise OR — a fundamentally different shape of type. A worked example: typedef NS_OPTIONS(NSUInteger, EHTextFormatting) { EHTextFormattingNone = 0, EHTextFormattingBold = 1 << 0, EHTextFormattingItalic = 1 << 1, EHTextFormattingUnderline = 1 << 2 }; declares a bitmask type where any combination of bold/italic/underline can be set at once. The Clang Importer turns this into a Swift OptionSet-conforming struct, EHTextFormatting, which Swift code combines with array-literal-like syntax ([.bold, .italic]) rather than manual bitwise-OR — a much safer and more idiomatic Swift shape for the same underlying bitmask concept. Neither of these clean translations happens for a plain, un-macro'd C enum — it is specifically NS_ENUM/NS_OPTIONS that carry the metadata making this possible.
• NS_ENUM declares a closed set of mutually exclusive values → imports as a genuine Swift enum with exhaustive switch support. • NS_OPTIONS declares a combinable bitmask → imports as a Swift OptionSet-conforming struct, combined with array-literal syntax. • A plain, un-macro'd C enum gets none of this — NS_ENUM/NS_OPTIONS are what carry the metadata enabling clean Swift import. • The Clang Importer also strips the redundant type-name prefix from each case (see the naming rules section below).
NSError Conventions: The Elegant Automatic throws Translation
Objective-C has a long-standing convention for reporting recoverable errors: a method takes an NSError ** out-parameter (a pointer to a pointer, letting the method write an error object back through it) and signals failure through its normal return value — returning NO for a BOOL-returning method, or nil for an object-returning method — while writing a populated NSError * into the out-parameter only in the failure case. This convention is entirely a documented pattern in Objective-C; nothing in the language enforces it, it's just what Cocoa APIs have consistently done for decades.
The Clang Importer specifically recognizes this exact convention and translates it into something considerably more elegant on the Swift side: a genuine throws method, with the NSError ** parameter removed entirely from the Swift-facing signature. Consider a before/after: the Objective-C declaration - (BOOL)writeToFile:(NSString *)path error:(NSError )error; imports into Swift as func write(toFile path: String) throws. Swift code calls it with try someObject.write(toFile: somePath), wrapped in a do/catch (or propagated with try in a throwing context) — the BOOL return value and the NSError out-parameter both simply vanish from the Swift signature, replaced by Swift's own native error-handling syntax.
The same pattern applies to an object-returning method: - (nullable NSData *)dataFromURL:(NSURL *)url error:(NSError **)error; (note the nullability annotation on the return type, doing its own separate job) imports as func data(from url: URL) throws -> Data — a non-optional Data return type, because in the successful case the object-or-nil-plus-NSError convention guarantees a real, non-nil object, and any failure is represented by the thrown error rather than by returning nil. This is a genuinely elegant piece of engineering in the bridging system: it takes a decades-old, purely conventional (not language-enforced) Objective-C pattern and turns it into a first-class Swift language feature automatically, provided your Objective-C method signature follows the convention precisely.
• Objective-C convention: NSError out-parameter + BOOL NO / nil return on failure, with an NSError written back only on failure. • The Clang Importer detects this exact pattern and converts it to a Swift throws method, dropping the NSError parameter entirely. • - (BOOL)writeToFile:...error:(NSError **)error; → func write(toFile:) throws, called with try/do-catch in Swift. • An object-returning error method with a nullable return imports its Swift return type as non-optional, since failure is now represented by the thrown error.
Completion Handlers, Blocks, Protocol Exposure, and Unavailable APIs
An Objective-C block parameter imports into Swift as a closure, and this translation is mostly seamless: a method taking void (^completion)(NSData * _Nullable data, NSError * _Nullable error) imports with a matching Swift closure parameter (Data?, Error?) -> Void, and Swift code passes an ordinary closure literal (including Swift's trailing-closure syntax) wherever the Objective-C API expects a block. Nullability annotations on the block's own parameters flow through exactly as described earlier, so a well-annotated completion-handler block imports with properly optional or non-optional closure parameter types rather than defaulting to implicitly-unwrapped optionals throughout. It's worth mentioning, without over-teaching Swift's concurrency model here, that modern Swift can further wrap a completion-handler-based Objective-C API with async/await — either through Apple's own async-annotated overlays on system frameworks, or by hand-writing a small withCheckedContinuation-based wrapper around an imported completion-handler method — but that wrapping is a Swift-side technique layered on top of the ordinary closure import described above, not a separate importer feature.
Protocols cross the boundary too, when marked @objc. An @objc protocol can be declared in either language and adopted from either language — an Objective-C class can adopt a protocol declared in Swift (via the generated interface) and vice versa. Objective-C's long-standing @optional protocol-method convention (methods a conforming type may implement but isn't required to) maps onto Swift's own optional-requirement handling for @objc protocols: Swift code checks for the presence of an optional requirement with optional chaining (delegate?.optionalMethod?()) rather than Swift's native, non-@objc approach of providing default implementations via a protocol extension — a small but important distinction, since Swift-only protocols (not marked @objc) cannot use @optional at all and must rely on protocol extensions with default implementations instead.
Finally, some Objective-C constructs simply have no honest Swift representation and are marked unavailable in the generated Swift interface rather than mistranslated — the same 'refuse rather than silently misrepresent' philosophy already seen with @objc restrictions. Certain C-style variadic functions (functions taking a ... argument list, like +stringWithFormat: handled specially, but many arbitrary custom variadic C functions cannot be represented safely) and unsupported macro-based declaration patterns are common examples. The underlying reason is conceptual, not a list to memorize: Swift's type system and calling conventions are simply stricter and more structured than raw C/Objective-C allows, and rather than generate a Swift declaration that would behave unsafely or unpredictably, the importer marks the construct unavailable, forcing you to either avoid calling it from Swift, or write a small Objective-C (or Swift) shim around it that does have a clean, translatable interface.
• An Objective-C block parameter imports as a matching Swift closure parameter; nullability on the block's own parameters flows through too. • Modern Swift can additionally wrap a completion-handler API with async/await, layered on top of the ordinary closure import, not a separate feature. • @objc protocols can be declared in and adopted from either language; @optional requirements map to Swift's optional-chaining pattern for @objc protocols. • Constructs with no honest Swift representation (some C variadic functions, unsupported macro patterns) are marked unavailable rather than mistranslated.
Naming and Import Rules: From a Flat Namespace to Module Scoping
Objective-C has no built-in module or namespace system for ordinary classes and functions — every class name lives in one single, flat, global namespace shared by your entire app and every framework it links against, which is precisely why Objective-C code relies so heavily on two- or three-letter class-name prefixes (NS, UI, EH for a hypothetical project prefix) purely as a manual, convention-based substitute for real namespacing, to avoid name collisions. Swift, by contrast, has genuine module-scoped naming: every type lives inside its declaring module, and a name only needs to be disambiguated with a module prefix (Foundation.Data vs. some other module's Data) when there's an actual collision, which is rare.
Because of this difference, the Clang Importer doesn't just copy Objective-C names into Swift verbatim — it applies a set of systematic renaming rules designed to make the imported API look like it was designed for Swift's naming conventions in the first place, not like a mechanically ported Objective-C API. The most visible of these rules strips a class's own name as a redundant prefix from its factory methods and constants: an Objective-C class method like + (instancetype)downloadStateIdle declared on a class named EHDownloadState-adjacent naming, or more concretely a case like EHDownloadStateIdle from the NS_ENUM example earlier, has its redundant EHDownloadState prefix stripped when imported as a Swift enum case, becoming simply .idle — because in Swift, EHDownloadState.idle is already fully, unambiguously qualified by the enum's own type name, and repeating that name inside the case would be pure redundancy.
This same prefix-stripping instinct extends to ordinary factory methods too: a class named EHNetworkRequest with a class method + (instancetype)requestWithURL:(NSURL *)url typically imports with the redundant leading type reference smoothed out to read naturally as EHNetworkRequest(url:), callable as EHNetworkRequest(url: someURL) — closer to how a Swift initializer reads than how the original Objective-C factory-method name read. None of this changes the underlying Objective-C code at all; it is purely a presentation-layer transformation applied by the Clang Importer when generating the Swift-facing view of your headers, which is exactly why the same Objective-C API can look meaningfully more natural from the Swift side than its literal Objective-C spelling would suggest.
• Objective-C has one flat, global namespace for class names, hence the convention of manual prefixing (NS, UI, EH, ...). • Swift has real module-scoped naming, so most of that manual prefixing becomes redundant once imported. • The Clang Importer strips a type's own name as a redundant prefix from its enum cases and factory methods when generating the Swift interface. • This renaming is purely a Swift-facing presentation transformation — the underlying Objective-C source and its actual names are unchanged.
NS_ENUM / NS_OPTIONS and an NSError-Convention Method Declared in an Objective-C Header
EHDownloadState imports into Swift as enum EHDownloadState { case idle, inProgress, completed, failed }. EHTextFormatting imports into Swift as a Swift OptionSet-conforming struct, combined like [.bold, .italic]. writeString:toPath:error: imports as func write(_ text: String, toPath path: String) throws — no NSError ** in the Swift signature. The NS_ASSUME_NONNULL region means every otherwise-unannotated pointer in it is treated as nonnull, avoiding implicitly-unwrapped optionals.
// NS_ENUM: a closed set of mutually exclusive values
typedef NS_ENUM(NSInteger, EHDownloadState) {
EHDownloadStateIdle,
EHDownloadStateInProgress,
EHDownloadStateCompleted,
EHDownloadStateFailed
};
// NS_OPTIONS: a combinable bitmask
typedef NS_OPTIONS(NSUInteger, EHTextFormatting) {
EHTextFormattingNone = 0,
EHTextFormattingBold = 1 << 0,
EHTextFormattingItalic = 1 << 1,
EHTextFormattingUnderline = 1 << 2
};
NS_ASSUME_NONNULL_BEGIN
@interface EHFileWriter : NSObject
// NSError-convention method -> imports into Swift as a throws method
- (BOOL)writeString:(NSString *)text
toPath:(NSString *)path
error:(NSError **)error;
@end
NS_ASSUME_NONNULL_ENDCode Examples
Because EHUserProfile inherits from NSObject and every member here is an ordinary Objective-C property/method with no un-representable Swift feature involved, the whole class is automatically visible to Swift without writing @objc anywhere by hand — NSObject subclasses with plain Objective-C members are @objc-visible by default. In the generated interface, Swift sees roughly: class EHUserProfile: NSObject { var displayName: String; var avatarURL: String?; init(displayName: String); func hasAvatar() -> Bool }. Note displayName is non-optional String (nonnull by the ASSUME_NONNULL region) while avatarURL is String? (explicitly nullable) — this is the nullability payoff from the earlier lesson, made concrete.
@property (nonatomic, copy, nullable) NSString *avatarURL;
trackTitles is declared NSArray<NSString *> * rather than a bare NSArray *, and trackDurations is declared with both generic key and value types — both annotations are read directly by the Clang Importer and change the resulting Swift type, not just the Objective-C-side compile-time checking. Swift sees var trackTitles: [String] and var trackDurations: [String: NSNumber] — properly typed, immediately usable Swift collections. Had trackTitles been declared as a bare NSArray *, Swift would instead see var trackTitles: [Any], forcing a cast like title as! String before every use.
@property (nonatomic, copy) NSArray<NSString *> *trackTitles;
EHDownloadState is a closed set of mutually exclusive states declared with NS_ENUM; EHTextFormatting is a combinable bitmask declared with NS_OPTIONS. Both macros carry metadata (beyond a plain C enum) that the Clang Importer reads to decide which Swift shape to generate. Swift sees var state: EHDownloadState where EHDownloadState is a genuine Swift enum usable in an exhaustive switch (case .idle, .inProgress, .completed, .failed — prefix stripped), and var labelFormatting: EHTextFormatting where EHTextFormatting is an OptionSet struct combined as task.labelFormatting = [.bold, .italic] rather than with manual bitwise OR.
typedef NS_OPTIONS(NSUInteger, EHTextFormatting) {... } Both methods follow the Objective-C NSError ** + failure-return convention exactly: the BOOL method signals failure by returning NO, the NSData * method signals failure by returning nil, and both write a populated NSError into the error out-parameter only when they fail. The Clang Importer converts both into throwing Swift methods: func write(_ text: String, toPath path: String) throws and func readData(fromPath path: String) throws -> Data — note the second one returns non-optional Data, since a thrown error now represents the failure case that nil used to represent. Swift call site: try writer.write("hello", toPath: "/tmp/x.txt") or let data = try writer.readData(fromPath: "/tmp/x.txt"), both wrapped in do/catch or a throwing function.
• (BOOL)writeString:(NSString *)text toPath:(NSString *)path error:(NSError **)error;
Common mistake: Deviating even slightly from the exact NSError convention (for example, returning void instead of BOOL/an object, or naming the parameter something other than a trailing error:(NSError )error) can prevent the Clang Importer from recognizing the pattern, leaving Swift with the raw NSError ** parameter instead of a clean throws method.
EHDownloadDelegate is declared as an ordinary Objective-C @protocol adopting NSObject, with one @required method and one @optional method — a completely standard Objective-C delegate protocol pattern. Because it implicitly participates in the Objective-C runtime (protocols adopting NSObject and used this way are exposed to Swift), a Swift class can adopt EHDownloadDelegate directly. Swift must implement didUpdateState(_:) (required), while didUpdateProgress(_:) is called with optional chaining: delegate?.downloadTask?(task, didUpdateProgress: 0.5) — the double question mark reflecting both 'delegate might be nil' and 'this optional requirement might not be implemented'.
• (void)downloadTask:(EHDownloadTask *)task didUpdateProgress:(double)progress;
// EHUserProfile.h
NS_ASSUME_NONNULL_BEGIN
@interface EHUserProfile : NSObject
@property (nonatomic, copy) NSString *displayName;
@property (nonatomic, copy, nullable) NSString *avatarURL;
- (instancetype)initWithDisplayName:(NSString *)displayName NS_DESIGNATED_INITIALIZER;
- (BOOL)hasAvatar;
@end
NS_ASSUME_NONNULL_END// EHPlaylist.h
NS_ASSUME_NONNULL_BEGIN
@interface EHPlaylist : NSObject
@property (nonatomic, copy) NSArray<NSString *> *trackTitles;
@property (nonatomic, copy) NSDictionary<NSString *, NSNumber *> *trackDurations;
- (void)addTrackTitle:(NSString *)title;
@end
NS_ASSUME_NONNULL_END// EHDownloadTypes.h
typedef NS_ENUM(NSInteger, EHDownloadState) {
EHDownloadStateIdle,
EHDownloadStateInProgress,
EHDownloadStateCompleted,
EHDownloadStateFailed
};
typedef NS_OPTIONS(NSUInteger, EHTextFormatting) {
EHTextFormattingNone = 0,
EHTextFormattingBold = 1 << 0,
EHTextFormattingItalic = 1 << 1,
EHTextFormattingUnderline = 1 << 2
};
@interface EHDownloadTask : NSObject
@property (nonatomic, assign) EHDownloadState state;
@property (nonatomic, assign) EHTextFormatting labelFormatting;
@end// EHFileWriter.h
NS_ASSUME_NONNULL_BEGIN
@interface EHFileWriter : NSObject
- (BOOL)writeString:(NSString *)text
toPath:(NSString *)path
error:(NSError **)error;
- (nullable NSData *)readDataFromPath:(NSString *)path
error:(NSError **)error;
@end
NS_ASSUME_NONNULL_END// EHDownloadDelegate.h
NS_ASSUME_NONNULL_BEGIN
@protocol EHDownloadDelegate <NSObject>
@required
- (void)downloadTask:(EHDownloadTask *)task didUpdateState:(EHDownloadState)state;
@optional
- (void)downloadTask:(EHDownloadTask *)task didUpdateProgress:(double)progress;
@end
NS_ASSUME_NONNULL_ENDThe Objective-C → Swift Import Pipeline
Your Objective-C header (with nullability, lightweight generics, NS_ENUM/NS_OPTIONS, and NSError-convention methods) is read by the Clang Importer, which produces a translated Swift interface — the same interface visible when you jump-to-definition on an imported Objective-C symbol from Swift code.
Objective-C Header
↓
Clang Importer
↓
Swift Interface
↓
Swift CodeImportant Rules
A Swift class must inherit from NSObject, directly or indirectly, to be usable from Objective-C at all.
@objc members are dispatched dynamically through the Objective-C runtime, not through Swift's normal static/vtable dispatch.
Generics, tuples, and non-Objective-C-compatible value types generally cannot be marked @objc — the runtime has no vocabulary for them.
Annotate Objective-C headers with nullability (nullable/nonnull) so Swift gets real Optionals instead of implicitly-unwrapped optionals everywhere.
Use lightweight generics (NSArray<T> *, etc.) so Swift-imported collections are properly typed instead of importing as [Any].
Use NS_ENUM for closed mutually-exclusive value sets and NS_OPTIONS for combinable bitmasks — never a plain, un-macro'd C enum for a public API.
Follow the exact NSError ** out-parameter + BOOL/nil-return convention precisely if you want the Clang Importer to generate a Swift throws method.
Expect some Objective-C constructs (certain variadic C functions, unsupported macro patterns) to be marked unavailable in Swift rather than mistranslated.
Why This Matters: Incrementally Migrating a Real Codebase
The single most common real-world scenario this lesson prepares you for is an incremental Objective-C-to-Swift migration on an existing app: a team keeps a large, working Objective-C codebase largely as-is, while writing all new features in Swift and gradually rewriting existing pieces. That only works smoothly because of everything covered here — new Swift view controllers can be instantiated from old Objective-C storyboard-loading code via the generated -Swift.h interface; existing Objective-C model classes, once properly annotated with nullability and lightweight generics, immediately feel like native Swift types to the new Swift code consuming them; and Objective-C delegate protocols continue working unmodified as long as they're marked @objc, letting new Swift classes adopt decades-old delegate protocols without either side needing to change. Teams that neglect the annotation work (nullability, lightweight generics, NS_ENUM/NS_OPTIONS) don't lose interoperability entirely — the Clang Importer still produces something — but they pay for it every day in the form of implicitly-unwrapped optionals, [Any] casts, and un-Swift-y bitmask constants throughout the Swift codebase, which is exactly the gap this lesson exists to close.
class Downloader { func start() { } } // no NSObject inheritance, then trying to use it from Objective-C class Downloader: NSObject { @objc func start() { } }
A Swift class with no NSObject ancestor has no Objective-C runtime identity (no isa pointer, no runtime class registration), so it cannot appear in the generated -Swift.h interface at all, regardless of how simple its members look.
@property (nonatomic, strong) NSString *name; // no nullability annotation, no NS_ASSUME_NONNULL region
NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy) NSString *name; NS_ASSUME_NONNULL_END
Without nullability information, the Clang Importer falls back to an implicitly-unwrapped optional (String!) in Swift, which compiles fine but can crash at runtime if the value is ever actually nil — annotating (or wrapping in an ASSUME_NONNULL region) gives Swift a real, safe Optional or non-optional type instead.
@property (nonatomic, copy) NSArray *scores; // bare NSArray, no element type
@property (nonatomic, copy) NSArray<NSNumber *> *scores;
A bare NSArray * imports into Swift as [Any], forcing a cast on every element before use; the lightweight-generic-annotated version imports as a properly typed [NSNumber], usable immediately.
typedef enum { StateIdle, StateRunning, StateDone } EHState; // plain C enum, no NS_ENUM typedef NS_ENUM(NSInteger, EHState) { EHStateIdle, EHStateRunning, EHStateDone };
A plain C enum lacks the metadata NS_ENUM provides, so it does not import as a clean, exhaustive-switchable Swift enum the same way — using NS_ENUM (or NS_OPTIONS for a bitmask) is what enables the clean Swift-side translation.
• (void)saveWithCompletion:(void (^)(BOOL success, NSError *error))completion; // BOOL+NSError via a completion block, not the NSError convention - (BOOL)save:(NSError )error; // or, if async behavior is truly needed, keep the completion-handler shape and accept it imports as a closure, not as throws
The automatic throws conversion only applies to the specific synchronous NSError ** out-parameter convention; passing success/error through a completion-handler block is a different, equally valid pattern, but it imports as an ordinary Swift closure parameter, not as a throws method — conflating the two leads to confusion about why a given method didn't become throws in Swift.
What generates the ProductModuleName-Swift.h header, and what does it contain? A. It is hand-written by the developer and contains every Objective-C class in the project B. It is auto-generated by the build from @objc-visible Swift declarations, letting Objective-C code import it and call that Swift code C. It is generated by the Clang Importer from Objective-C headers, exposing them to Swift D. It is a configuration file listing which Swift files should be compiled
B. It is auto-generated by the build from @objc-visible Swift declarations, letting Objective-C code import it and call that Swift code — ProductModuleName-Swift.h is auto-generated by the Swift compiler's build output for the Swift → Objective-C direction: it contains Objective-C-flavored declarations for every Swift declaration marked (explicitly or implicitly) @objc, which Objective-C .m files then #import normally.
Why can't a generic Swift method (func process<T>(_ item: T)) be marked @objc? A. Because @objc methods must be static B. Because the Objective-C runtime has no representation for a Swift generic type parameter — its method signatures require fixed, concrete types C. Because generics were added to Swift after @objc was introduced and Apple never updated the importer D. Because generic methods must always return void
B. Because the Objective-C runtime has no representation for a Swift generic type parameter — its method signatures require fixed, concrete types — The Objective-C runtime's messaging model is built around fixed, concrete types it can describe in its own type-encoding system; a Swift generic parameter T has no such fixed representation, so the compiler refuses to let a generic method be marked @objc.
Why must a Swift class inherit from NSObject to be usable from Objective-C? A. It's an arbitrary Apple requirement with no technical reason B. NSObject subclasses compile faster than plain Swift classes C. Objective-C's message-sending model requires an isa pointer and runtime class registration, which only NSObject (and its runtime support) provides D. Because Swift requires all classes to eventually inherit from NSObject anyway
C. Objective-C's message-sending model requires an isa pointer and runtime class registration, which only NSObject (and its runtime support) provides — Objective-C's dynamic dispatch depends on every instance having an isa pointer identifying its class and every class participating in the runtime's class-registration and method-dispatch machinery — a plain Swift class has none of that unless it descends from NSObject.
What happens to dispatch when a Swift method is marked @objc? A. It is dispatched dynamically through the Objective-C messaging machinery, rather than through Swift's normal static/vtable dispatch B. Nothing changes; @objc only affects header generation, never runtime dispatch C. It becomes faster because Objective-C dispatch is always faster than Swift dispatch D. It is dispatched using Swift's protocol witness tables exclusively
A. It is dispatched dynamically through the Objective-C messaging machinery, rather than through Swift's normal static/vtable dispatch — @objc members are dispatched through Objective-C's dynamic message-sending machinery, a real behavioral and performance difference from Swift's normal static or vtable-based dispatch, and it's what enables dynamic runtime features like KVO and swizzling on those members.
An Objective-C property declared @property (nonatomic, copy) NSArray<NSString *> *tags; inside an NS_ASSUME_NONNULL region imports into Swift as which type? A. [Any] B. [String]? C. [String] D. NSArray<String>
C. [String] — The lightweight generic <NSString *> makes the element type import as String rather than Any, and the NS_ASSUME_NONNULL region (with no explicit nullable) makes both the array and its usage nonnull/non-optional, giving the properly typed, non-optional Swift array [String].
An Objective-C method - (BOOL)saveToPath:(NSString *)path error:(NSError )error; following the standard NSError convention imports into Swift as which kind of method? A. A completion-handler-based async method B. An ordinary method still taking an NSError parameter, unchanged C. A throws method, with the NSError ** parameter and BOOL return removed from the Swift signature D. It cannot be imported into Swift at all
C. A throws method, with the NSError parameter and BOOL return removed from the Swift signature — The Clang Importer specifically recognizes the NSError out-parameter + BOOL/nil-return convention and converts it into a genuine Swift throws method, dropping the error parameter and boolean/nil signaling from the Swift-facing signature entirely.
Why does the Clang Importer strip a type's own name as a prefix from its factory methods and enum cases when generating the Swift interface (e.g. EHDownloadStateIdle becoming .idle)? A. Because Swift forbids underscores in identifiers B. Because Swift has module- and type-scoped naming, so repeating the type's own name inside a member reference is redundant there, unlike in Objective-C's flat global namespace where prefixing is the only defense against name collisions C. Because Objective-C headers are randomly renamed for obfuscation purposes D. Because it makes the generated header file shorter
B. Because Swift has module- and type-scoped naming, so repeating the type's own name inside a member reference is redundant there, unlike in Objective-C's flat global namespace where prefixing is the only defense against name collisions — Objective-C relies on manual name prefixes because it has one flat, global namespace; Swift has genuine module/type scoping, so EHDownloadState.EHDownloadStateIdle would be pure redundancy — the Clang Importer strips the redundant prefix to produce idiomatic Swift naming like EHDownloadState.idle.
Given the Objective-C declaration - (nullable NSArray<NSString *> *)tagsForItem:(NSString *)itemID error:(NSError **)error; inside an NS_ASSUME_NONNULL region (so itemID itself is implicitly nonnull), write out exactly what its imported Swift signature looks like, and explain the role each annotation (nullable on the return type, the generic <NSString *>, and the NSError ** convention) plays in producing that signature.
The NSError ** convention converts this into a throws method, dropping the error parameter from Swift and the NSData/NSArray success/nil signaling entirely — failure is now represented by a thrown error. The <NSString *> lightweight generic makes the array element type import as String, not Any. Because failure is now represented by the thrown error rather than by returning nil, the successful-case return type no longer needs to be optional in Swift even though it was marked nullable in Objective-C (the nullable there was really doing double duty for the old nil-on-failure convention). The resulting Swift signature is: func tags(forItem itemID: String) throws -> [String].
Work through the three transformations independently: what does the NSError ** convention do to the shape of the method overall, what does the generic annotation do to the array's element type, and what does the explicit nullable on the return type mean once the method becomes throwing (does a thrown error make the nullable annotation on the success-path return value still necessary)?