Objective-C to Swift Migration
Incrementally migrating an Objective-C codebase to Swift while keeping the app functional throughout. Swift and Objective-C coexist in the same project using bridging headers, so you can migrate one file at a time without a big-bang rewrite.
When to Migrate
- New features should be written in Swift -- there is no reason to start new files in ObjC.
- Fixing bugs in an ObjC file is a good opportunity to migrate that file to Swift.
- ObjC code is hard to maintain (manual retain/release patterns, stringly-typed APIs, verbose syntax).
- You want Swift-only APIs: SwiftUI, async/await, actors,
@Observable, Swift Testing. - You want stronger type safety and fewer runtime crashes (optionals, enums, value types).
When NOT to Migrate
- ObjC code is stable, well-tested, and rarely touched. If it works and nobody needs to change it, leave it.
- Performance-critical C/C++ interop code. ObjC has zero-cost C interop; Swift requires bridging.
- Team is not yet comfortable with Swift. Training should come before migration.
- Massive codebase with a tight deadline. Migration takes time and introduces risk.
- Third-party ObjC libraries you do not control. Wrap them rather than rewrite them.
Concept Mapping
| Objective-C | Swift | Notes |
|---|---|---|
@interface / @implementation |
class / struct / enum |
Choose value types where possible |
@property (nonatomic, strong) |
var |
let for readonly equivalents |
@property (nonatomic, copy) |
var (value types are copied) |
Strings, arrays are value types in Swift |
@property (nonatomic, readonly) |
let or private(set) var |
Depends on mutability needs |
NSString |
String |
Bridged automatically |
NSArray / NSDictionary |
[Element] / [Key: Value] |
Typed collections |
NSNumber |
Int, Double, Bool |
Use native types |
NSError ** |
throws |
Error handling via try/catch |
Block (^) |
Closure ({ }) |
Syntax difference, same concept |
id |
Any |
Prefer specific types |
NS_ENUM |
enum: Int (or CaseIterable) |
Natively typed |
NS_OPTIONS |
OptionSet |
Struct-based |
dispatch_queue_t + GCD |
async/await + actors |
Modern concurrency |
| Category | Extension | Same concept, different syntax |
Protocol (@protocol) |
Protocol | Swift protocols are more powerful |
#pragma mark - |
// MARK: - |
Section separators |
@selector |
#selector |
Compile-time checked |
@try/@catch |
do/try/catch |
Swift does not catch ObjC exceptions |
instancetype |
Self |
Return type inference |
nullable / nonnull |
Optional / non-optional |
Direct mapping |
Bridging Header Setup
ObjC to Swift (Bridging Header)
The bridging header lets Swift code import ObjC classes. Xcode creates it automatically when you first add a Swift file to an ObjC project.
// ProjectName-Bridging-Header.h
// Import ObjC headers you want visible to Swift
#import "NetworkManager.h"
#import "UserModel.h"
#import "DatabaseHelper.h"Build setting: SWIFT_OBJC_BRIDGING_HEADER = ProjectName/ProjectName-Bridging-Header.h
Rules:
- Keep the bridging header minimal. Only import headers that Swift code actually needs.
- Do not import every header -- it slows down compilation and creates unnecessary coupling.
- Remove headers from the bridging header as you migrate those classes to Swift.
Swift to ObjC (Generated Header)
Xcode auto-generates a ProjectName-Swift.h header that exposes Swift classes to ObjC. Import it in ObjC files:
// In any .m file that needs Swift classes
#import "ProjectName-Swift.h"
// Never import this in .h files (causes circular imports)Build setting: DEFINES_MODULE = YES
@objc Attribute
Swift classes and members are not visible to ObjC by default. Mark them explicitly:
// Single class with specific members visible to ObjC
@objc class UserManager: NSObject {
@objc var currentUser: User?
@objc func logOut() {
currentUser = nil
}
// Not visible to ObjC (no @objc)
func swiftOnlyMethod() { }
}@objcMembers
When most members need ObjC visibility, use @objcMembers on the class instead of marking each member:
@objcMembers
class LegacyService: NSObject {
var isReady: Bool = false // Visible to ObjC
func start() { } // Visible to ObjC
func stop() { } // Visible to ObjC
// Opt out specific members
@nonobjc func swiftOnlyHelper() { }
}Incremental Migration Strategy
Migrate leaves first, trunks last. Start with classes that have the fewest dependencies on other ObjC classes.
Recommended Order
- Model classes -- Fewest dependencies, easiest to test. Convert
NSObjectsubclasses to Swift structs or classes. - Utility / helper classes -- String formatters, date helpers, validators. Usually standalone.
- Network layer -- API clients, request builders. Good candidate for async/await modernization.
- ViewModels / Presenters -- Business logic layer. May have more dependencies on models and services.
- View controllers -- Most complex, most dependencies. Migrate last.
For each file, the process is:
- Create the new
.swiftfile - Translate the ObjC code to Swift
- Update the bridging header (remove the migrated header, add any new Swift
@objcvisibility) - Update all ObjC callers to use the Swift version (via
ProjectName-Swift.h) - Delete the old
.hand.mfiles - Run all tests
Common Translation Patterns
Blocks to Closures
// Objective-C
typedef void (^CompletionHandler)(NSData * _Nullable data, NSError * _Nullable error);
- (void)fetchDataWithCompletion:(CompletionHandler)completion {
[self.session dataTaskWithURL:self.url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
completion(data, error);
}];
}// Swift (direct translation)
func fetchData(completion: @escaping (Data?, Error?) -> Void) {
session.dataTask(with: url) { data, response, error in
completion(data, error)
}.resume()
}
// Swift (modern -- use async/await)
func fetchData() async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}NSError** to throws
// Objective-C
- (BOOL)saveToFile:(NSString *)path error:(NSError **)error {
NSData *data = [self serializedData];
if (!data) {
if (error) {
*error = [NSError errorWithDomain:@"MyApp" code:100 userInfo:@{
NSLocalizedDescriptionKey: @"Serialization failed"
}];
}
return NO;
}
return [data writeToFile:path options:NSDataWritingAtomic error:error];
}// Swift
enum FileError: LocalizedError {
case serializationFailed
var errorDescription: String? {
switch self {
case .serializationFailed: return "Serialization failed"
}
}
}
func save(to path: String) throws {
guard let data = serializedData() else {
throw FileError.serializationFailed
}
try data.write(to: URL(fileURLWithPath: path), options: .atomic)
}Delegates to Protocols
// Objective-C
@protocol DownloadDelegate <NSObject>
@required
- (void)downloadDidFinish:(Download *)download;
- (void)download:(Download *)download didFailWithError:(NSError *)error;
@optional
- (void)download:(Download *)download didUpdateProgress:(float)progress;
@end
@interface Download : NSObject
@property (nonatomic, weak) id<DownloadDelegate> delegate;
@end// Swift
protocol DownloadDelegate: AnyObject {
func downloadDidFinish(_ download: Download)
func download(_ download: Download, didFailWith error: Error)
func download(_ download: Download, didUpdateProgress progress: Float) // Optional via default impl
}
extension DownloadDelegate {
// Default implementation makes it optional
func download(_ download: Download, didUpdateProgress progress: Float) { }
}
class Download {
weak var delegate: DownloadDelegate?
}Enums (NS_ENUM and NS_OPTIONS)
// Objective-C
typedef NS_ENUM(NSInteger, Priority) {
PriorityLow,
PriorityMedium,
PriorityHigh
};
typedef NS_OPTIONS(NSUInteger, Permissions) {
PermissionsRead = 1 << 0,
PermissionsWrite = 1 << 1,
PermissionsDelete = 1 << 2
};// Swift
enum Priority: Int, CaseIterable {
case low
case medium
case high
}
struct Permissions: OptionSet {
let rawValue: UInt
static let read = Permissions(rawValue: 1 << 0)
static let write = Permissions(rawValue: 1 << 1)
static let delete = Permissions(rawValue: 1 << 2)
static let readWrite: Permissions = [.read, .write]
static let all: Permissions = [.read, .write, .delete]
}Singletons
// Objective-C
@implementation MyManager
+ (instancetype)sharedManager {
static MyManager *instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[MyManager alloc] init];
});
return instance;
}
@end// Swift
class MyManager {
static let shared = MyManager()
private init() { }
}Categories to Extensions
// Objective-C -- UIColor+Hex.h / UIColor+Hex.m
@interface UIColor (Hex)
+ (UIColor *)colorWithHex:(NSUInteger)hex;
@end
@implementation UIColor (Hex)
+ (UIColor *)colorWithHex:(NSUInteger)hex {
return [UIColor colorWithRed:((hex >> 16) & 0xFF) / 255.0
green:((hex >> 8) & 0xFF) / 255.0
blue:(hex & 0xFF) / 255.0
alpha:1.0];
}
@end// Swift
extension UIColor {
convenience init(hex: UInt) {
self.init(
red: CGFloat((hex >> 16) & 0xFF) / 255.0,
green: CGFloat((hex >> 8) & 0xFF) / 255.0,
blue: CGFloat(hex & 0xFF) / 255.0,
alpha: 1.0
)
}
}KVO to Combine or @Observable
// Objective-C (KVO)
[self.user addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:nil];
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object
change:(NSDictionary *)change context:(void *)context {
if ([keyPath isEqualToString:@"name"]) {
[self updateNameLabel];
}
}// Swift (iOS 17+ -- @Observable)
@Observable
class User {
var name: String = ""
}
// SwiftUI view automatically observes changes
struct UserView: View {
var user: User
var body: some View {
Text(user.name) // Re-renders when name changes
}
}Nullability Annotations to Optionals
// Objective-C
@interface UserService : NSObject
- (nullable User *)findUserWithID:(nonnull NSString *)userID;
- (nonnull NSArray<User *> *)allUsers;
- (void)saveUser:(nonnull User *)user completion:(nullable void (^)(NSError * _Nullable))completion;
@end// Swift
class UserService {
func findUser(withID userID: String) -> User? {
// nullable return -> Optional
}
func allUsers() -> [User] {
// nonnull return -> non-optional
}
func saveUser(_ user: User, completion: ((Error?) -> Void)? = nil) {
// nullable block -> optional closure
}
}Mixed-Language Project Management
NS_SWIFT_NAME for Better Swift APIs
Improve how ObjC APIs appear in Swift without changing the ObjC interface:
// Objective-C
typedef NS_ENUM(NSInteger, ABCRecordType) {
ABCRecordTypePerson,
ABCRecordTypeOrganization
} NS_SWIFT_NAME(Record.RecordType);
@interface ABCManager : NSObject
+ (instancetype)managerWithConfiguration:(ABCConfiguration *)config
NS_SWIFT_NAME(init(configuration:));
- (void)fetchRecordsOfType:(ABCRecordType)type
NS_SWIFT_NAME(fetchRecords(ofType:));
@endNS_REFINED_FOR_SWIFT
Hide the ObjC version and provide a better Swift wrapper:
// Objective-C
@interface DataStore : NSObject
- (NSInteger)countForType:(NSString *)type NS_REFINED_FOR_SWIFT;
@end// Swift extension provides the refined API
extension DataStore {
// The ObjC method is available as __countForType(_:)
func count(for type: RecordType) -> Int {
return __count(forType: type.rawValue)
}
}Module Map Considerations
For framework targets or when using @import:
// module.modulemap
framework module MyFramework {
umbrella header "MyFramework.h"
export *
module * { export * }
}Build setting: DEFINES_MODULE = YES must be enabled for the -Swift.h header to be generated.
Testing During Migration
- Keep existing ObjC tests running. Do not delete ObjC tests until the Swift replacement is verified.
- Write new tests in Swift Testing or XCTest. All new test files should be Swift.
- Test the bridging layer. Write tests that exercise ObjC code calling Swift and Swift code calling ObjC to catch bridging issues.
- Verify no behavior changes. After migrating each file, run the full test suite. The migrated Swift code should produce identical results.
- Test nullability boundaries. ObjC and Swift handle
nildifferently. Verify thatnilvalues pass correctly across the bridge.
// Test that Swift class works when called from ObjC patterns
import XCTest
class BridgingTests: XCTestCase {
func testSwiftClassAccessibleFromObjC() {
// Verify @objc class can be instantiated and used
let manager = UserManager()
manager.logOut()
XCTAssertNil(manager.currentUser)
}
func testNullabilityBridging() {
// Verify nil handling across the bridge
let service = UserService()
let user = service.findUser(withID: "nonexistent")
XCTAssertNil(user) // Should be nil, not crash
}
}Common Mistakes
// ❌ Swift class not inheriting from NSObject (invisible to ObjC)
@objc class MyHelper {
@objc func doWork() { }
}
// Error: only classes that inherit from NSObject can be @objc
// ✅ Inherit from NSObject for ObjC visibility
@objc class MyHelper: NSObject {
@objc func doWork() { }
}// ❌ Importing -Swift.h in a .h file (circular import)
// MyClass.h
#import "ProjectName-Swift.h" // Causes circular dependency
// ✅ Use forward declaration in .h, import in .m
// MyClass.h
@class MySwiftClass; // Forward declaration
// MyClass.m
#import "ProjectName-Swift.h" // Import here// ❌ Assuming Swift structs/enums are visible to ObjC
@objc struct Point { // Error: structs cannot be @objc
var x: Double
var y: Double
}
// ✅ Use class inheriting from NSObject, or keep as Swift-only type
@objc class Point: NSObject {
@objc var x: Double
@objc var y: Double
@objc init(x: Double, y: Double) {
self.x = x
self.y = y
}
}// ❌ Using Swift-only types in @objc methods
@objc class DataService: NSObject {
@objc func process(items: [String: Any]) -> Result<Data, Error> {
// Error: Result is not representable in Objective-C
}
}
// ✅ Use ObjC-compatible types at the boundary
@objc class DataService: NSObject {
@objc func process(items: [String: Any]) throws -> Data {
// throws bridges to NSError** in ObjC
}
}// ❌ Not handling nil vs NSNull from ObjC collections
// ObjC NSDictionary can contain NSNull, which bridges to NSNull in Swift, not nil
let value = dict["key"] // Could be NSNull, not nil
// ✅ Check for NSNull explicitly
if let value = dict["key"], !(value is NSNull) {
// Safe to use value
}// ❌ Migrating everything at once (big-bang rewrite)
// This almost always fails for non-trivial apps
// ✅ Migrate one file at a time, test after each migration
// Keep both languages compiling and running throughoutChecklist
- Bridging header (
ProjectName-Bridging-Header.h) set up and minimal -
DEFINES_MODULE = YESin build settings - Migration order planned: models -> utilities -> network -> viewmodels -> view controllers
-
@objc/@objcMembersapplied where Swift classes need ObjC visibility - Swift classes that need ObjC visibility inherit from
NSObject -
-Swift.honly imported in.mfiles, never.hfiles (use forward declarations) -
NS_SWIFT_NAME/NS_REFINED_FOR_SWIFTused for ObjC APIs consumed from Swift - NSError** patterns replaced with
throws - Blocks replaced with closures (or async/await for modern code)
- NS_ENUM / NS_OPTIONS replaced with
enum/OptionSet - Nullability annotations mapped to Swift optionals
- Existing ObjC tests still pass after each file migration
- Bridging layer tested (ObjC calling Swift and Swift calling ObjC)
- Old
.hand.mfiles deleted after successful migration and testing - Bridging header updated (migrated headers removed)