Skip to content

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 @objcMembers for 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:

objc
// ProductName-Bridging-Header.h
#ifndef ProductName_Bridging_Header_h
#define ProductName_Bridging_Header_h

#import "DataManager.h"
#import "User.h"
#import "TokenStore.h"

#endif

What Swift sees

Given this ObjC header:

objc
// 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_END

Swift sees (roughly):

swift
class DataManager: NSObject {
    init?(url: URL)
    func loadAll(completion: @escaping ([Any]?, Error?) -> Void)
}

Notice the imports: NSURLURL, 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:

swift
@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"):

objc
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:

swift
@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 an associatedtype cannot be @objc.
  • @objc protocols 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 / any opaque types (some View) — no runtime representation in the ObjC model.
  • Swift enum without a raw type — only Int-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 subclassing NSObject instead.
  • 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:

AnnotationSwift importMeaning
_NonnullT (non-optional)Cannot be nil.
_NullableT?May be nil.
_Null_unspecifiedT! (implicitly unwrapped)Unknown — legacy/untyped.
null_resettableT! (with a nonnil setter)Property that reads nonnil but can be set to nil to reset to a default (e.g. title).

Applied inline:

objc
- (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:

objc
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_END

Rules of the audit region:

  • nullable / nonnull / null_resettable are the property-attribute spellings (@property (nonatomic, nullable, copy) NSString *x;).
  • _Nullable / _Nonnull are 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 the BOOL + trailing NSError ** shape to throws.
  • 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:

objc
@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:

objc
- (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:

objc
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
// ObjC header
- (double)timeoutSeconds NS_REFINED_FOR_SWIFT;
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:

objc
// #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:

objc
#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_END

Bridging header exposes it to Swift:

objc
// ProductName-Bridging-Header.h
#import "DataManager.h"

Swift side — UserListViewController.swift:

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:

  • DataManager is a plain NSObject subclass — automatically bridgeable.
  • The header is in the audit region, so url imports as URL (not URL!), delegate imports as id<DataManagerDelegate>?.
  • NS_SWIFT_NAME cleans up the Swift call sites (fetchUsers(), users(forRole:)) without changing ObjC.
  • The Swift class conforms to the ObjC DataManagerDelegate protocol — Swift protocols that conform to ObjC protocols work because the methods are @objc-compatible (the ObjC protocol implies it).
  • delegate is weak on 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).

NextTesting ObjC code