ObjC-Swift interop
Hub › iOS › Objective-C › ObjC-Swift interop
Goal
You will move code across the Objective-C / Swift boundary in both directions — exposing ObjC to Swift via a bridging header, exposing Swift to ObjC via @objc / @objcMembers, and shaping the imported API with nullability annotations, lightweight generics, NS_SWIFT_NAME, and NS_REFINED_FOR_SWIFT. You will learn what cannot cross (Swift-only features), how to make an ObjC header import as clean Swift, and how to combine #selector / #keyPath for compile-time-checked strings. After this page you can maintain a mixed-source codebase in either direction.
Prerequisites
The two directions
Interop is bidirectional and the mechanisms differ:
- ObjC → Swift: a bridging header (
ProductName-Bridging-Header.h) lists the ObjC headers you want visible. The Swift compiler reads it and generates Swift declarations for every ObjC class, protocol, and method it imports. One-way: ObjC cannot see Swift this way. - Swift → ObjC: annotate Swift declarations with
@objc(or@objcMembersfor a whole class). The compiler emits ObjC-compatible metadata into the module. ObjC code then#imports the generated header (ProductName-Swift.h).
A mixed app often uses both: a Swift view controller calls an ObjC model class (bridging header), and that same ObjC model class calls back into a Swift delegate (generated header + @objc).
The bridging header — ObjC into Swift
Creating it
When you add your first .m file to a Swift-target Xcode project, Xcode offers to create the bridging header for you. Accept, and it creates ProductName-Bridging-Header.h. (You can also create the file manually and set its path under Build Settings → Swift Compiler - General → Objective-C Bridging Header.)
The header is just a list of #imports — nothing else. Everything you import here becomes visible to all Swift files in the target:
// ProductName-Bridging-Header.h
#ifndef ProductName_Bridging_Header_h
#define ProductName_Bridging_Header_h
#import "DataManager.h"
#import "User.h"
#import "TokenStore.h"
#endifWhat Swift sees
Given this ObjC header:
// DataManager.h
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface DataManager : NSObject
- (nullable instancetype)initWithURL:(NSURL *)url;
- (void)loadAllWithCompletion:(void (^)(NSArray * _Nullable items, NSError * _Nullable error))completion;
@end
NS_ASSUME_NONNULL_ENDSwift sees (roughly):
class DataManager: NSObject {
init?(url: URL)
func loadAll(completion: @escaping ([Any]?, Error?) -> Void)
}Notice the imports: NSURL → URL, NSArray → [Any], NSError * _Nullable in a completion → Error?, the nullable init → a failable initializer init?. The compiler does this translation from the ObjC type encodings and the nullability annotations.
One bridging header per target
The bridging header is per-target, not per-file. Every ObjC class you want in Swift goes in the same header. For a framework target (not an app), the equivalent is the umbrella header — the framework's main .h that imports all public headers, which the Swift compiler reads automatically.
@objc and @objcMembers — Swift into ObjC
Mark a Swift class, method, property, or initializer with @objc to make it visible to ObjC:
@objc class Counter: NSObject {
@objc var count: Int = 0
@objc func increment() { count += 1 }
@objc func reset(to value: Int) { count = value }
}ObjC then uses it through the generated header (#import "ProductName-Swift.h"):
Counter *counter = [[Counter alloc] init];
[counter increment];
[counter resetTo:5];
NSLog(@"%ld", (long)counter.count);@objcMembers (Swift 4+) on a class exposes all members to ObjC at once — equivalent to annotating every property, method, and init individually:
@objcMembers class Counter: NSObject {
var count: Int = 0 // @objc
func increment() { count += 1 } // @objc
}What cannot be exposed
Only @objc-compatible declarations cross. These cannot:
- Generics with
associatedtype— protocol associated types have no ObjC representation. A protocol with anassociatedtypecannot be@objc. @objcprotocols cannot have associated types and cannot require methods with Swift-only signatures (tuples,some/any).- Tuples as return or parameter types — no ObjC encoding. Use a struct or multiple parameters.
some/anyopaque types (some View) — no runtime representation in the ObjC model.- Swift
enumwithout a raw type — onlyInt-backed enums (or those with@objc) bridge; enums with associated values do not. - Structs without
@objc— only@objc-bridgable structs (and there are few; most don't bridge). Use a class subclassingNSObjectinstead. - Top-level functions and global variables — only members of
NSObject-derived classes (or@objc-annotated enums) cross reliably.
When the compiler rejects an @objc annotation, the reason is almost always one of these: the declaration uses a Swift-only type feature with no ObjC counterpart.
Nullability annotations
Nullability is the highest-leverage annotation you can add. Without it, every imported pointer becomes an implicitly-unwrapped optional (!) in Swift — usable, but you lose compile-time nil safety and a typo becomes a runtime crash.
The annotations, applied to ObjC properties, parameters, and return types:
| Annotation | Swift import | Meaning |
|---|---|---|
_Nonnull | T (non-optional) | Cannot be nil. |
_Nullable | T? | May be nil. |
_Null_unspecified | T! (implicitly unwrapped) | Unknown — legacy/untyped. |
null_resettable | T! (with a nonnil setter) | Property that reads nonnil but can be set to nil to reset to a default (e.g. title). |
Applied inline:
- (void)loadUser:(NSString * _Nonnull)userID
completion:(void (^ _Nullable)(User * _Nullable user))completion;NS_ASSUME_NONNULL_BEGIN / END
Writing _Nonnull on every pointer is noisy. The audit-style macros flip the default for a whole region: inside the block, every simple pointer is assumed _Nonnull unless you explicitly mark it _Nullable. You opt out of nonnull rather than opt in:
NS_ASSUME_NONNULL_BEGIN
@interface User : NSObject
@property (nonatomic, copy) NSString *name; // nonnull (default)
@property (nonatomic, copy, nullable) NSString *nickname; // nullable (explicit)
- (nullable instancetype)initWithID:(NSString *)userID; // nullable return (explicit)
- (BOOL)fetchFriend:(User * _Nullable * _Nullable)outFriend;// nullable out-param
@end
NS_ASSUME_NONNULL_ENDRules of the audit region:
nullable/nonnull/null_resettableare the property-attribute spellings (@property (nonatomic, nullable, copy) NSString *x;)._Nullable/_Nonnullare the type-annotation spellings (on parameters, returns, ivars, block types).- Inside the region,
NSError **is special: both pointers are inferred_Nullable, and the Swift importer maps theBOOL+ trailingNSError **shape tothrows. - Cocoa's standard collection types (
NSArray,NSDictionary) need their contents annotated too — see lightweight generics below.
Audit an existing header in one pass
To migrate a legacy header without touching every line: wrap it in NS_ASSUME_NONNULL_BEGIN / END, then add nullable / _Nullable only to the pointers that can actually be nil. The compiler warns about each remaining unannotated pointer in a non-audited file, so the macro region silences the noise and forces you to make a deliberate call on each nullable case.
Lightweight generics
ObjC's NSArray<NSString *> and NSDictionary<NSString *, User *> are lightweight generics — compile-time type parameters that bridge cleanly to Swift typed arrays and dictionaries:
@property (nonatomic, copy) NSArray<User *> *friends; // → Swift: [User]
@property (nonatomic, copy) NSDictionary<NSString *, NSNumber *> *scores; // → [String: NSNumber]Without the type parameter, Swift imports NSArray * as [Any] — every element needs a cast. Always parameterize collections in headers meant for Swift. Note these generics are erased at runtime (ObjC's runtime has no reified generics); they are a compile-time contract only, and Swift respects them on import.
NS_SWIFT_NAME — renaming for Swift
The ObjC method fetchUserWithID: imports into Swift as fetchUser(withID:) — the default translation trims "With" prefixes and lowercases. If you want a different Swift name, NS_SWIFT_NAME overrides it:
- (void)fetchUserWithID:(NSString *)userID NS_SWIFT_NAME(fetchUser(id:));Now Swift sees fetchUser(id:). The macro takes a Swift-style signature string — full label and parameter names. It works on classes, protocols, methods, properties, enums, and enum cases:
typedef NS_ENUM(NSInteger, LogLevel) {
LogLevelError NS_SWIFT_NAME(.error),
LogLevelWarning NS_SWIFT_NAME(.warning),
LogLevelInfo NS_SWIFT_NAME(.info),
} NS_SWIFT_NAME(LogLevel);This imports as a Swift enum LogLevel: Int { case error, warning, info }. NS_SWIFT_NAME is the primary tool for making an ObjC API feel native in Swift without changing the ObjC call sites.
NS_REFINED_FOR_SWIFT
Sometimes the auto-imported Swift API is fundamentally awkward — a method that should be a Swift computed property, a set of constants that should be an OptionSet, an initializer that should take a Duration. NS_REFINED_FOR_SWIFT tells the importer to rename the declaration with a leading __ and suppress it from autocompletion, so you can write a thin Swift wrapper that calls through to it:
// ObjC header
- (double)timeoutSeconds NS_REFINED_FOR_SWIFT;// Swift extension
extension NetworkService {
var timeout: Duration {
.seconds(__timeoutSeconds) // calls the refined ObjC method
}
}Swift callers see only timeout: Duration, not the raw __timeoutSeconds. Use this when you want to wrap rather than rename — typical for converting error codes to a Swift Error enum, or wrapping a C-style API into a Swift Result.
#selector and #keyPath — compile-time-checked strings
ObjC APIs that take a method name or a key path historically took a string (@selector(handleTap:) is partially checked, but key paths were bare @"name" strings — typos crashed at runtime). Swift (and modern ObjC via the # macros) gives compile-time checking:
// #selector — verifies the method exists at compile time
[button addTarget:self
action:#selector(handleTap:)
forControlEvents:UIControlEventTouchUpInside];
- (void)handleTap:(UIButton *)sender { /* ... */ }
// #keyPath — verifies the key path exists at compile time
[self.user addObserver:self
forKeyPath:#keyPath(User.name)
options:NSKeyValueObservingOptionNew
context:&ctx];Both #selector and #keyPath are checked by the compiler against the actual declarations — rename a property and the build fails instead of crashing at runtime. Prefer them over string literals everywhere a Cocoa API accepts one.
A complete mixed-source example
An ObjC DataManager that loads users, consumed by a Swift view controller via the bridging header, with the ObjC side calling back into a Swift delegate.
ObjC side — DataManager.h:
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@class DataManager;
@protocol DataManagerDelegate <NSObject>
- (void)dataManager:(DataManager *)manager didLoadUsers:(NSArray *)users;
@end
@interface DataManager : NSObject
@property (nonatomic, weak, nullable) id<DataManagerDelegate> delegate;
- (instancetype)initWithURL:(NSURL *)url;
- (void)fetchUsers NS_SWIFT_NAME(fetchUsers());
- (NSArray *)usersForRole:(NSString *)role NS_SWIFT_NAME(users(forRole:));
@end
NS_ASSUME_NONNULL_ENDBridging header exposes it to Swift:
// ProductName-Bridging-Header.h
#import "DataManager.h"Swift side — UserListViewController.swift:
import UIKit
class UserListViewController: UIViewController, DataManagerDelegate {
let dataManager: DataManager
init(url: URL) {
self.dataManager = DataManager(url: url) // imported initializer
super.init(nibName: nil, bundle: nil)
dataManager.delegate = self
}
required init?(coder: NSCoder) { fatalError("init(coder:) not used") }
override func viewDidLoad() {
super.viewDidLoad()
dataManager.fetchUsers() // NS_SWIFT_NAME'd; clean Swift call
}
func dataManager(_ manager: DataManager, didLoadUsers users: [Any]) {
// delegate callback fired from ObjC into Swift
let names = users.compactMap { ($0 as? User)?.name }
DispatchQueue.main.async { /* render */ }
}
}What makes this work, end to end:
DataManageris a plainNSObjectsubclass — automatically bridgeable.- The header is in the audit region, so
urlimports asURL(notURL!),delegateimports asid<DataManagerDelegate>?. NS_SWIFT_NAMEcleans up the Swift call sites (fetchUsers(),users(forRole:)) without changing ObjC.- The Swift class conforms to the ObjC
DataManagerDelegateprotocol — Swift protocols that conform to ObjC protocols work because the methods are@objc-compatible (the ObjC protocol implies it). delegateisweakon the ObjC side, so the Swift view controller is not retained by its data manager.
What's next
You can now move code across the language boundary in either direction and shape the import to feel native. The final page in the Objective-C section covers testing — setting up an XCTestCase target in ObjC, the XCTAssert* assertion macros, testing delegate callbacks and async code with XCTestExpectation, and the mocking patterns (and why Swift needs them less).
Next → Testing ObjC code