Skip to content

Swift-ObjC interop

Hub › iOS › Swift › Swift-ObjC interop

Goal

You will expose Swift code to Objective-C using @objc and @objcMembers, understand what ObjC-mobile Swift features exist, use #selector/#keyPath for compile-time-checked calls, set up a bridging header, and navigate the requirements for mixed-source projects. After this page you can add Swift to an ObjC project or expose Swift APIs to ObjC consumers.

Prerequisites

The two directions

Interop is bidirectional, and each direction uses a different mechanism:

  • Swift → ObjC: annotate Swift declarations with @objc. The compiler emits ObjC-compatible metadata into the module's generated header (ProductName-Swift.h). ObjC code #imports this header.
  • 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.

This page covers the Swift → ObjC direction from Swift's perspective. For the reverse direction (bridging header, nullability annotations, lightweight generics), see the ObjC-Swift interop page in the Objective-C section.

@objc attribute

The @objc attribute exposes a Swift member to Objective-C. It tells the compiler to emit the declaration in the generated -Swift.h header so that ObjC can call it.

The requirements are strict:

  1. The enclosing type must be an NSObject subclass (or use @objc on an @objcMembers class). Pure Swift classes (no NSObject) cannot be exposed.
  2. All parameter and return types must have ObjC equivalents. No tuples, no generics with associated types, no some/any, no closures that capture non-@objc types.
  3. Methods must use ObjC-compatible dispatch. Instance methods become ObjC instance methods; static methods become class methods.
swift
import Foundation

@objc class Counter: NSObject {
    @objc var count: Int = 0

    @objc func increment() {
        count += 1
    }

    @objc func reset(to value: Int) {
        count = value
    }
}

On the ObjC side:

objc
#import "ProductName-Swift.h"

Counter *counter = [[Counter alloc] init];
[counter increment];
[counter resetTo:5];
NSLog(@"%ld", (long)counter.count);

What happens when you add @objc to a method: the compiler generates an ObjC method pointer (IMP) that calls through Swift's vtable or witness table. The method receives an objc_msgSend-compatible signature: id _Nonnull (*)(id _Nonnull, SEL _Nonnull, ...). This is the only way an ObjC caller can invoke a Swift method — through the selector-based dispatch that the ObjC runtime understands.

What cannot cross the @objc boundary

Swift featureObjC equivalentCan cross?
structNo struct supportNo — only classes visible
enum with associated valuesNo enum with payloadsNo — only Int-backed @objc enums
tuple (Int, String)No tuple typeNo — use a struct or separate params
Generics func<T>(x: T)No genericsNo — concrete types only
some View (opaque types)No opaque type supportNo
any SomeProtocol (existentials)id<SomeProtocol>Yes, if protocol is @objc
Closures with captureBlocks (^)Yes, if @convention(block)
async functionsNo async/awaitNo — expose a completion-handler wrapper

The rule: if the Swift feature relies on static dispatch, value semantics, or compile-time-only resolution, ObjC cannot see it. Only types and methods that map to ObjC's dynamic message dispatch model are bridgeable.

@objc on initializers

Marking an initializer @objc exposes it so ObjC can allocate the object:

swift
@objc class User: NSObject {
    @objc let id: String
    @objc let name: String

    @objc init(id: String, name: String) {
        self.id = id
        self.name = name
    }
}

A failable initializer init? becomes - (nullable instancetype)initWith... in ObjC — the ? maps to nullable.

@objc on properties

Properties marked @objc generate ObjC @property declarations. The property attributes (strong/weak, readwrite/readonly, copy/assign) are inferred from the Swift declaration:

swift
@objc class User: NSObject {
    @objc let id: String              // readonly, copy
    @objc var name: String            // readwrite, copy
    @objc weak var parent: User?      // weak, nullable
    @objc private(set) var age: Int   // readonly in ObjC
}

@objcMembers

@objcMembers (Swift 4+) is a class-level annotation that exposes all eligible members to ObjC — equivalent to applying @objc to every property, method, subscript, and initializer:

swift
@objcMembers class DataStore: NSObject {
    var items: [String] = []          // implicitly @objc
    func addItem(_ item: String) { }  // implicitly @objc
    func removeAll() { }              // implicitly @objc
}

When to use @objcMembers vs explicit @objc

UseReason
@objcMembersWhole class is an ObjC utility or delegate, every member needs exposure. Convenient for thin wrappers.
Explicit @objcOnly specific members need exposure — keeps the -Swift.h header lean, avoids accidental exposure of Swift-only helpers.

@objcMembers is convenient but coarse. It exposes every member that can legally be @objc. A method that happens to use a tuple internally (and is therefore non-@objc-compatible) will silently not be exposed — the compiler does not warn. With explicit @objc, the compiler tells you immediately which methods cannot bridge.

Dynamic dispatch and @objc dynamic

Swift normally dispatches methods statically (through the vtable) or via protocol witness tables. To make a property observable via KVO, you need dynamic dispatch — the method call must go through objc_msgSend so that the ObjC runtime can intercept it:

swift
@objc class ObservableCounter: NSObject {
    @objc dynamic var count: Int = 0
}

Without dynamic, the compiler may inline or devirtualize the getter/setter. The dynamic modifier forces the compiler to emit ObjC-compatible accessor methods (-count and -setCount:) that always go through message dispatch. This is required for:

  • KVOaddObserver:forKeyPath:options:context: needs to observe -setCount:.
  • Core Data@NSManaged properties require dynamic dispatch.
  • Cocoa binding — AppKit bindings need KVO-compliant accessors.

The dynamic keyword does not require @objc (it implies it), but the two are typically paired:

swift
@objcMembers class Counter: NSObject {
    dynamic var count: Int = 0       // implicitly @objc
}

What dynamic does at the machine level

Without dynamic, calling counter.count = 5 produces a direct write to the property's offset in the object's memory. The setCount: Selector is never called. With dynamic, the compiler emits a call to objc_msgSend(counter, @selector(setCount:), 5), which resolves through the ObjC runtime's method cache and calls the dynamically-registered setCount: implementation.

This indirection is slower (a few nanoseconds per call) but enables the runtime to intercept the access for KVO, Core Data faulting, and Cocoa bindings.

The NSObject requirement

Only classes that inherit from NSObject (or NSManagedObject) can expose members to ObjC. The reason is structural: ObjC objects have an isa pointer at offset 0 that points to their class metadata, and ObjC method dispatch works through the class's method list. Swift native objects do not have an isa pointer — they use a different runtime layout.

This means:

  • Swift structs are invisible to ObjC. A struct cannot be passed to an ObjC method that expects id. Use a class wrapping the struct, or use NSValue boxing.
  • Pure Swift classes (no NSObject root) are invisible to ObjC. A class that does not inherit from NSObject cannot have @objc members.
  • Only one inheritance root. A Swift class can inherit from NSObject or another ObjC-compatible class (like UIViewController), but it cannot inherit from a pure Swift class and also expose members to ObjC.

The workaround for structs: make an @objc class wrapper:

swift
struct User {
    let id: String
    let name: String
}

@objc class UserBridge: NSObject {
    @objc let id: String
    @objc let name: String

    init(user: User) {
        self.id = user.id
        self.name = user.name
    }
}

#selector() and #keyPath()

In ObjC, selectors and key paths were historically string-based. #selector() and #keyPath() (available from both Swift and modern ObjC) make them compile-time-checked:

swift
button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)

@objc func buttonTapped(_ sender: UIButton) {
    // handle tap
}

The #selector expression takes a reference to a method. The compiler verifies that the method exists, is @objc-exposed, and matches the expected signature. If you rename buttonTapped, the build fails — no runtime crash.

For key paths used with KVO or Cocoa binding:

swift
observer.observe(user, keyPath: #keyPath(User.name), options: [.new])

#keyPath produces a String — the ObjC key path — but the compiler checks that the path is valid for the type at compile time. Rename the name property and the build breaks.

Why these are better than strings

#selector / #keyPathString literal
Compile-time checkYes — method/property existence verifiedNo — typo crashes at runtime
RefactoringRename propagates automaticallyManual search-and-replace
AutocompleteXcode suggests selectors and key pathsNo help
Nil safetyCannot produce a nil selectorTypo produces an unexpected nil

In Swift, prefer #selector and #keyPath over any string-based alternative. There is no good reason to use a string literal for these APIs.

Mixed-source project example

A Swift view controller that uses a bridging header to call an ObjC data manager (ObjC → Swift), and the ObjC data manager has nullability annotations and lightweight generics for clean Swift import.

Step 1 — Create the bridging header.

When you add a .m file to a Swift-target Xcode project, Xcode prompts you to create ProductName-Bridging-Header.h. Accept. The header imports the ObjC headers you want Swift to see:

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

Step 2 — The ObjC header with nullability and generics.

objc
// DataManager.h
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@class User;

@interface DataManager : NSObject

@property (nonatomic, copy, readonly) NSArray<User *> *users;
@property (nonatomic, strong, readonly) NSURL *baseURL;

- (instancetype)initWithBaseURL:(NSURL *)url NS_DESIGNATED_INITIALIZER;
- (void)refreshWithCompletion:(void (^)(NSError * _Nullable error))completion;

@end

NS_ASSUME_NONNULL_END

Step 3 — Swift uses the ObjC class naturally.

swift
// UserListViewController.swift
import UIKit

class UserListViewController: UIViewController {

    let manager: DataManager

    init(baseURL: URL) {
        self.manager = DataManager(baseURL: baseURL)   // NSURL → URL
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) { fatalError("not implemented") }

    override func viewDidLoad() {
        super.viewDidLoad()
        manager.refresh { error in                     // completion handler bridged
            if let error = error {
                print("Error: \(error)")
            }
        }
    }
}

What makes this work:

  • DataManager is an NSObject subclass — automatically bridgeable.
  • The NS_ASSUME_NONNULL_BEGIN region means baseURL imports as URL (not URL!).
  • NSArray<User *> imports as [User] — typed, not [Any].
  • The completion: block imports as ((Error?) -> Void)? — the NSError? becomes Error?.
  • The designated initializer initWithBaseURL: imports as init(baseURL:).

Step 4 — Swift code exposed back to ObjC.

swift
@objc class UserListViewController: UIViewController {
    @objc let manager: DataManager

    @objc func reloadData() {
        manager.refresh { [weak self] error in
            self?.updateUI()
        }
    }

    @objc private func updateUI() {
        // update table view
    }
}

Now ObjC code (or Cocoa's target-action system) can call userListVC.reloadData() through the generated -Swift.h header.

Objective-C++ (.mm files)

When a project mixes C++ with ObjC or Swift, the file extension .mm tells the compiler to use the Objective-C++ dialect — a superset that combines C++ and ObjC syntax in one compilation unit:

cpp
// Wrapper.mm
#import "Wrapper.h"
#include <c++/library.h>      // C++ header

@implementation Wrapper {
    std::unique_ptr<CppClass> _cpp;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        _cpp = std::make_unique<CppClass>();
    }
    return self;
}

- (void)process {
    _cpp->doWork();
}

@end

The .mm file is an Objective-C++ translation unit — it can call C++ methods and send ObjC messages. The result is an ObjC object that Swift can see through the bridging header, with the C++ implementation hidden inside the .mm file.

Strategy for mixed C++/Swift:

  1. Write an ObjC wrapper class in .mm files that exposes a pure ObjC interface (no C++ types).
  2. Put the ObjC interface in a .h file (no C++ headers).
  3. Import the .h in the bridging header.
  4. Swift calls the wrapper as a normal ObjC class, unaware of the C++ underneath.

This is the standard pattern for using C++ libraries (like Core ML, audio DSP, game engines) from Swift.

Comparison with ObjC-Swift interop

AspectSwift → ObjCObjC → Swift
Mechanism@objc / @objcMembersBridging header
Generated fileProductName-Swift.hCompiler reads header directly
Visibility controlExplicit @objc (opt-in) or @objcMembers (class-wide)#import in bridging header (opt-in)
Value typesCannot cross — must wrap in classCannot cross — ObjC has no structs
NullabilityInferred from Swift (? → nullable)Must annotate in ObjC header
GenericsConcrete types onlyLightweight generics bridge cleanly

The asymmetry is important: Swift → ObjC requires the Swift side to opt in to ObjC compatibility (every @objc annotation is a deliberate decision). ObjC → Swift requires the ObjC side to be imported in the bridging header (every import is a deliberate decision). Both directions are opt-in, and neither direction exposes a type's internals by default.

What's next

You can now expose Swift code to ObjC, use #selector and #keyPath for compile-time-safe calls, and set up a mixed-source project. You have completed both language sections.

Browse the Objective-C section for the ObjC perspective on interop, or revisit the Beginning tier to apply what you have learned.