Modern Swift Without Rewriting Everything: Wrapping Objective-C with Codable
When adding new features to our Music Theory Learning app, we don't always have the opportunity, or the need, to rewrite every Objective-C class in Swift. While implementing our Daily Challenge feature, one task type was already backed by a mature Objective-C implementation. At the same time, the surrounding architecture was written in Swift and relied on `Codable` for persistence. We wanted `ChallengeTaskImpl` to remain `Codable`, but `ScaleDegreeChordTask` is an Objective-C class that predates Swift's serialization APIs. Rather than extending the Objective-C class to support Swift's serialization model, we introduced a small Swift wrapper that became the integration point between the two.
The wrapper integrates seamlessly with the rest of the domain model. `ChallengeTaskImpl` remains a `Codable` enum whose cases represent the different challenge implementations, regardless of whether they are written in Swift or Objective-C.
enum ChallengeTaskImpl: Codable {
case noteReading
...
case scaleDegreeChord(ChallengeTaskImplScaleDegreeChord)
}
The `scaleDegreeChord` case stores a lightweight Swift wrapper instead of the Objective-C class directly. The wrapper owns the Objective-C instance while conforming to `Codable`. During encoding, it extracts the values required to recreate the task. During decoding, it reconstructs the Objective-C object from those values. Although `ChallengeTaskImplScaleDegreeChord` is a Swift value type, it owns a reference to the underlying Objective-C implementation. The wrapper defines the serialization boundary while the Objective-C object remains responsible for the task logic.
struct ChallengeTaskImplScaleDegreeChord: Codable {
let task: ScaleDegreeChordTask
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let task = ScaleDegreeChordTask()
task.qScaleId = try container.decode(String.self, forKey: .qScaleId)
task.qDegree = Int32(try container.decode(Int.self, forKey: .qDegree))
// Restore the remaining Objective-C state...
self.task = task
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(task.qScaleId, forKey: .qScaleId)
try container.encode(Int(task.qDegree), forKey: .qDegree)
// Encode the remaining state...
}
}
One advantage of this approach is that serialization remains entirely a Swift concern. The Objective-C class doesn't need to know anything about `Codable`, `Encoder`, or `Decoder`. Instead, the wrapper acts as a translation layer between the persisted representation and the runtime object. This keeps the Objective-C implementation focused on its original responsibility while the Swift wrapper provides the modern API expected by the rest of the application.
From the perspective of the UI, there is no distinction between Swift and Objective-C implementations. The view simply switches over the task type and presents the appropriate challenge view.
struct DailyChallengeView: View {
...
@ViewBuilder
private func taskView(for task: ChallengeTask) -> some View {
switch task.type {
case .noteReading, ...:
...
case .scaleDegreeChord:
DailyChallengeTaskView_Music(
task: task,
onNext: { ... }
)
}
}
}
We have found this approach to be a practical way of modernizing an existing codebase. Instead of rewriting mature Objective-C classes up front, we wrap them in small Swift types that provide modern APIs and protocol conformances. The result is a clean migration path where legacy code remains isolated and new Swift code doesn't need to know or care which language implements a particular feature.
