Swift & Objective-C Interoperability: A Practical Guide
Swift and Objective-C interoperability lets the two languages work together in the same project. This is important for modern Apple development because many apps still rely on Objective-C frameworks, legacy code, or APIs that Swift must call safely.
Quick answer: Swift can use Objective-C code, and Objective-C can use many Swift declarations, but only when the code is exposed correctly. The key tools are the bridging header, @objc, nullability annotations, and inherited Objective-C runtime features such as selectors and KVO-compatible patterns.
Difficulty: Intermediate
You'll understand this better if you know: basic Swift types and functions, how imports work, and the difference between reference types and value types.
1. What Is Swift & Objective-C Interoperability?
Swift and Objective-C interoperability is the ability for code written in one language to call, inherit from, or reference code written in the other. In practice, this means Swift can import Objective-C classes, methods, enums, and constants, while Objective-C can use selected Swift classes and members that are exposed to the Objective-C runtime.
- It helps you keep older Objective-C code while writing new code in Swift.
- It allows Apple frameworks that still expose Objective-C APIs to be used naturally in Swift.
- It depends on Objective-C runtime features, not on every Swift feature.
- Not all Swift types can be seen by Objective-C.
One important idea is that interoperability is not automatic for every declaration. Swift value types like structs and enums usually need special handling, and Objective-C can only see members that are compatible with the Objective-C runtime.
2. Why Swift & Objective-C Interoperability Matters
Many real-world Apple projects are mixed-language projects. A team may have years of Objective-C code, but new features are easier to build in Swift. Interoperability makes gradual migration possible instead of forcing a full rewrite.
It also matters because many system frameworks still expose Objective-C-style APIs. Even when you write pure Swift, you often interact with Objective-C under the hood through Foundation, UIKit, and AppKit.
Interoperability is especially useful when you need to:
- reuse existing Objective-C networking, database, or UI code
- expose a new Swift class to older Objective-C code
- inherit from Objective-C base classes such as NSObject
- connect Interface Builder outlets and actions
- work with selectors, delegates, and dynamic runtime behavior
3. Basic Syntax or Core Idea
At the core of interoperability are a few rules: Swift can import Objective-C automatically when the project is configured correctly, and Objective-C can only see Swift declarations that are exposed to the Objective-C runtime.
Importing Objective-C into Swift
In a mixed project, you usually import Objective-C headers through a bridging header. Swift then sees those types as if they were native imports.
// In a Swift file
import Foundation
let person = LegacyPerson()
person.setName("Ava")This works only if LegacyPerson is declared in an Objective-C header that Swift can see. If the type is not in the bridging header, Swift will report that it cannot find the symbol.
Exposing Swift to Objective-C
To make a Swift class visible to Objective-C, the class usually needs to inherit from NSObject and the members must be compatible with Objective-C. Methods and properties often need @objc, and the type must avoid Swift-only features that the Objective-C runtime cannot represent.
import Foundation
@objcMembers
class TaskManager: NSObject {
var count: Int = 0
func addTask() {
count += 1
}
}Here, @objcMembers exposes eligible members to Objective-C automatically. That makes the class easier to use from older code, but it also broadens what is visible, so it should be used deliberately.
4. Step-by-Step Examples
Example 1: Calling an Objective-C class from Swift
Suppose a legacy Objective-C class provides a simple utility. Once the header is included correctly, Swift can create and use it directly.
// Objective-C declaration conceptually visible to Swift
let logger = LegacyLogger()
logger.logMessage("Started app")This is the most common direction of interoperability: Swift consuming Objective-C APIs that already exist in a framework or app target.
Example 2: Exposing a Swift method to Objective-C
A Swift method becomes callable from Objective-C only if it is runtime-visible. A class that inherits from NSObject and marks the method appropriately can be used by Objective-C callers.
import Foundation
class Greeter: NSObject {
@objc func sayHello() {
print("Hello")
}
}The method can now be found by Objective-C code that imports the generated Swift header, as long as the rest of the project is configured for mixed-language compilation.
Example 3: Working with nullability
Objective-C APIs often use nullable and nonnull annotations. Swift converts those annotations into optional and non-optional types, which affects how you write safe code.
let title: String? = legacyDocument.documentTitle()
if let title {
print(title)
}This pattern appears frequently because Objective-C often cannot guarantee a value at compile time, so Swift represents that uncertainty with optionals.
Example 4: Using selectors for actions
Selectors are a classic Objective-C mechanism used by target-action patterns and some runtime APIs. Swift can work with them, but only methods with compatible signatures are eligible.
class ButtonHandler: NSObject {
@objc func buttonTapped() {
print("Tapped")
}
}
let handler = ButtonHandler()
let action = Selector(("buttonTapped"))This is useful when connecting Cocoa APIs that depend on the Objective-C runtime. It is not the same as a pure Swift closure-based callback.
5. Practical Use Cases
- Gradually migrating a large iOS app from Objective-C to Swift without rewriting everything at once.
- Using an Objective-C analytics, persistence, or networking layer from new Swift screens.
- Exposing a Swift model controller to older Objective-C view controllers.
- Connecting @IBOutlet and @IBAction in Interface Builder for mixed-language UI code.
- Calling Objective-C framework APIs that use delegates, selectors, and Foundation types.
- Keeping a shared utility module usable from both languages.
6. Common Mistakes
Mistake 1: Forgetting to inherit from NSObject
Swift code can compile fine as a pure Swift type, but Objective-C cannot see arbitrary Swift classes unless they participate in the Objective-C runtime. That usually means inheriting from NSObject.
Problem: Without an Objective-C-compatible base class, the Swift type is not exposed in the way Objective-C expects, so mixed-language calls fail.
class CacheManager {
@objc func clear() {
print("Cleared")
}
}Fix: Make the class inherit from NSObject when Objective-C needs to use it.
class CacheManager: NSObject {
@objc func clear() {
print("Cleared")
}
}The corrected version works because the class now participates in the Objective-C runtime.
Mistake 2: Assuming every Swift type is visible to Objective-C
Swift structs, generic types, and many enum designs do not bridge directly. Developers often expect Objective-C to see everything in the Swift module, but that is not how exposure works.
Problem: Objective-C cannot use a Swift struct such as a value-based model, and you may see build-time issues like an unavailable type or a missing generated symbol.
struct UserProfile {
let name: String
}Fix: Use an Objective-C-compatible class when the type must cross the language boundary.
class UserProfile: NSObject {
let name: String
init(name: String) {
self.name = name
}
}The corrected version works because Objective-C can interact with class instances exposed through the runtime.
Mistake 3: Ignoring nullability when calling Objective-C from Swift
Objective-C APIs may return nil, but Swift often imports those values as optionals. Treating them like guaranteed values leads to crashes or compiler errors.
Problem: If an imported Objective-C API returns an optional and you use it as if it were non-optional, Swift may report that you must unwrap it or the app may crash at runtime if you force-unwrap incorrectly.
let title = legacyDocument.documentTitle()
print(title.uppercased())Fix: Unwrap the optional before using it.
if let title = legacyDocument.documentTitle() {
print(title.uppercased())
}The corrected version is safe because it handles the Objective-C API’s possible absence of a value.
7. Best Practices
Prefer narrow exposure to Objective-C
Only expose the methods and properties Objective-C actually needs. Broad exposure with @objcMembers can be convenient, but it also increases the runtime surface area and makes accidental API exposure more likely.
class ReportStore: NSObject {
@objc func refresh() {}
}
// Better than exposing every member by default when only one is needed.This keeps your mixed-language boundary explicit and easier to maintain.
Use Swift-native types internally
Inside Swift-only code, prefer Swift structs, enums, and optionals where possible. Convert to Objective-C-compatible forms only at the boundary.
struct Settings {
var themeName: String
}This approach preserves Swift’s safety and clarity while still allowing interop at the edges.
Annotate Objective-C headers with nullability
Good nullability annotations make Swift imports much cleaner. A correctly annotated Objective-C API produces better Swift types and reduces optional-related mistakes.
// Objective-C header conceptually
// - (nullable NSString *)documentTitle;
// - (nonnull NSString *)appName;When the Objective-C side is annotated well, Swift code becomes easier to write and safer to use.
8. Limitations and Edge Cases
- Swift structs and many enums do not export to Objective-C in the same way classes do.
- Generic Swift APIs are usually not directly visible to Objective-C.
- Objective-C cannot understand Swift-only concepts like tuples, nested functions, or many advanced protocol features.
- Some Swift declarations become visible only after they are marked with Objective-C-compatible attributes.
- Dynamic runtime features such as selectors depend on NSObject and compatible method signatures.
- Imported Objective-C APIs may feel less Swifty because they often use reference semantics and nullable references.
One common surprise is that code can work in one direction but not the other. Swift can often consume Objective-C far more easily than Objective-C can consume arbitrary Swift because Swift has more language features than the Objective-C runtime can represent.
Another common issue is that method names may appear different after bridging. Swift often imports Objective-C APIs with more expressive labels, so the call site may not look like the original header.
9. Practical Mini Project
Here is a small mixed-language scenario: a Swift class manages the app state, and Objective-C-friendly exposure allows older code to trigger a refresh and read the state.
import Foundation
@objcMembers
class SessionStore: NSObject {
private(set) var isLoggedIn: Bool = false
func logIn() {
isLoggedIn = true
}
func logOut() {
isLoggedIn = false
}
}
let store = SessionStore()
store.logIn()
print(store.isLoggedIn)This example shows the basic shape of an interoperable Swift class. In a real project, older Objective-C code could call the same exposed methods and inspect the exposed property without needing a rewrite.
10. Key Points
- Swift and Objective-C can share code in the same project, but only through compatible declarations.
- Swift uses bridging and Objective-C runtime exposure to make mixed-language code work.
- NSObject, @objc, and nullability annotations are the most important tools.
- Swift value types do not automatically cross into Objective-C.
- Keeping the boundary small makes mixed-language code easier to maintain.
11. Practice Exercise
- Create a Swift class named PreferencesManager that stores a user’s display name.
- Expose one method that updates the name and one read-only property that returns it.
- Make the class Objective-C compatible.
- Verify that the exposed API uses only Objective-C-friendly types.
Expected output: A Swift class that Objective-C code could call to update and read the display name.
Hint: Use NSObject and expose only simple properties and methods.
Solution:
import Foundation
@objcMembers
class PreferencesManager: NSObject {
private(set) var displayName: String = "Guest"
func updateDisplayName(_ name: String) {
displayName = name
}
}12. Final Summary
Swift and Objective-C interoperability exists to help mixed-language Apple projects evolve safely. It lets Swift consume Objective-C APIs and allows selected Swift declarations to be visible to Objective-C, which is essential for migration and framework integration.
The most important concepts are runtime compatibility, bridging, and careful API exposure. If you remember to use NSObject when needed, mark exposed members with @objc or @objcMembers only when appropriate, and respect Objective-C nullability, you will avoid most of the common problems.
As a next step, learn how bridging headers, generated Swift interfaces, and nullability annotations work together in a mixed project. That will give you the confidence to move code between the two languages without breaking existing behavior.