Writing backward compatible Swift code
Adding new features to existing code can be really challenging - especially if that code is heavily used throughout one or many projects. Not only do we have to understand the impact our changes might have on the various call sites, but we often also risk introducing bugs and odd behavior if we make big changes to an existing API.
Backward compatibility can in many ways help us make such changes in a much smoother fashion. Avoiding completely replacing existing APIs and conventions can let us migrate to new implementations piece by piece - allowing us to add new features without breaking any of our existing code.
Especially in a large code base this can be really key - since it'll let us make small, atomic modifications without changing all the call sites at once - making changes easier to review, test and integrate. This week, let's take a look at a few different techniques that can help us make changes to our code base fully backward compatible.
Discardable results
Sometimes we find ourselves in situations where we want to turn a "fire and forget" kind of API into something that reports back some form of status or outcome. For example, let's say we're using a URLHandler
in our app in order to handle URLs from various sources (like deep links or web views):
class URLHandler {
func handle(_ url: URL) {
...
}
}
Right now our URLHandler
doesn't return anything from its handle
method - we simply ask it to handle a given URL, and it either successfully does so or fails, but we have no way of knowing the outcome.
Up to this point, that's been fine, but let's say we want to be able to react differently depending on whether a URL was successfully handled or not in a new feature we're building (we might, for example, want to display an error view in case we ended up with an invalid URL).
To make that happen, we'll start by creating an enum to represent a URLHandler
outcome - that either tells us that the URL was handled or gives us any error that was encountered, like this:
extension URLHandler {
enum Outcome {
case handled
case failed(Error)
}
}
However, if we just update our handle
method to return an Outcome
, we'll end up with warnings all over our project telling us that the result of calling that method wasn't used. Thankfully, we can avoid that by using @discardableResult
.
By adding the @discardableResult
attribute to our method, we essentially tell the compiler that it's completely fine to discard its result - giving us the flexibility to use the outcome in our new code, while still ignoring it in our old code.
Here's what our new implementation looks like:
class URLHandler {
@discardableResult
func handle(_ url: URL) -> Outcome {
do {
try validate(url)
} catch {
return .failed(error)
}
...
return .handled
}
}
The beauty of the above approach is that we've essentially added more power to our URLHandler
without impacting any of our existing code 👍.
Extended enum cases
Enums are a great way to model a set of mutually exclusive values, but sometimes we can end up painting ourselves into a bit of a corner when an enum is extensively used throughout a project. For example, let's say we're using the Navigator pattern in an app, and that we're using an enum to model the various destinations that a user can navigate to:
extension Navigator {
enum Destination {
case favorites
case bookList
case book(Book)
}
}
Then, at some point we realize that there are some parts of our code base that should be allowed to navigate to the book
destination, but don't have access to any full Book
models. To address that, we decide to change the book
destination to instead only require a given book's metadata, which also gives us better separation of concerns. We also make the naming of our destination a bit more clear, giving us a brand new bookDetails
enum case:
extension Navigator {
enum Destination {
case favorites
case bookList
case bookDetails(Book.Metadata)
}
}
Instead of having to go through our entire code base and update all usages of .book
to instead use .bookDetails
, let's make this change backward compatible. Using a static method we can create a sort of "fake" enum case that matches the old book
signature, like this:
extension Navigator.Destination {
static func book(_ book: Book) -> Navigator.Destination {
return .bookDetails(book.metadata)
}
}
By doing the above we can use the new bookDetails
destination in all of our new code, while still keeping our old code intact. Since Swift's type inference enables us to use dot notation even for things like static methods, all the call sites can remain exactly the same as before.
Default arguments
When adding new parameters to a function or initializer, using default arguments can be a great way to maintain backward compatibility - especially if the changes we want to make are purely additive. For example, let's say we have a function that lets us load data from a given URL:
func loadData(from url: URL,
then handler: @escaping (Result<Data>) -> Void) {
...
}
If we now want to add the option to customize how long it takes for a data loading request to time out - with full backward compatibility - we can do so by using a default argument that matches the value we were already using internally, like this:
func loadData(from url: URL,
timeout: TimeInterval = 10,
then handler: @escaping (Result<Data>) -> Void) {
...
}
Doing something like the above also has some nice self-documenting effects, in that we now clearly show what the default timeout value is in the method signature. All-in-all it's an easy way to maintain backward compatibility for arguments that don't necessarily need to be specified at the call site.
Using default arguments can also be a great way to refactor away singletons and to make our code easier to test.
Protocol extensions
Default argument values can also be used to make backward compatible changes to protocols. Even though we can't add default values directly to protocol function declarations, we can achieve the same result using protocol extensions.
Here we're using a Canvas
protocol to create an abstract interface to a custom rendering system that lets us draw various shapes:
protocol Canvas {
func draw(_ shape: Shape, at point: Point)
}
Now let's say we want to add support for applying a transform to any of the shapes that we're drawing. To do that, we'll add a transform
parameter to our draw
method, and in order to make that change fully backward compatible we accompany that change with a protocol extension that simply re-defines our old method and forwards all calls to our new one - like this:
protocol Canvas {
func draw(_ shape: Shape, at point: Point, transform: Transform)
}
extension Canvas {
func draw(_ shape: Shape, at point: Point) {
draw(shape, at: point, transform: .identity)
}
}
Not only does the above extension serve as a way to maintain backward compatibility, it also gives us a nice convenience API for when we're not interested in adding a transform when drawing a shape 👍.
Deprecations
Backward compatibility can be a bit of a "double edged sword" - in that we might add more code just to be able to keep serving our existing call sites, rather than just updating them in one go. Sometimes we want that extra code to stick around, for convenience or to simply avoid requiring our API users to change their code just because we needed to add a new feature, but sometimes we want to make it clear that the old API is going away.
Deprecations are a way to do just that. Just like how Apple uses deprecations in their SKDs and frameworks to give us hints and encouragement to move our code to more modern APIs, we can do the exact same thing for our own code as well.
For example, let's revisit our navigation destination code from before. Let's say we don't want to keep our "fake" book
enum case around for long, but instead just want to use it as a stepping stone for migrating to the new bookDetails
API. That's a great use case for a deprecation.
Using the @available
attribute together with the deprecated
keyword, we can formally deprecate our old API while also offering a hint as to which API that replaced it, like this:
extension Navigator.Destination {
@available(*, deprecated, renamed: "bookDetails")
static func book(_ book: Book) -> Navigator.Destination {
return .bookDetails(book.metadata)
}
}
One awesome thing about using deprecations like we do above is that Xcode will show warnings at all call sites and also offer fix-its to migrate to the new API - just like it would for Apple's own APIs! 🎉
We can also use deprecations with protocols, and even use a custom message in case we want to do something like point our colleagues or API users to a document that details how to migrate from the old API to the new one:
extension Canvas {
@available(*, deprecated, message: "See docs/MigratingToTransforms.md")
func draw(_ shape: Shape, at point: Point) {
draw(shape, at: point, transform: .identity)
}
}
Even though it's a bit of extra effort, adding more finely grained deprecations when replacing types and APIs can really help with communication - especially in an open source project or in a large team. Instead of being frustrated when a new version abruptly breaks their code, our API users will now get a clear indication of what changes that need to be made to their call sites.
Custom warnings
Finally, let's take a look at a feature introduced in Swift 4.2, which lets us place custom warnings inline in our code using the new #warning
directive.
When it comes to backward compatibility, using this new warning mechanism can be a great way to keep track of temporary or old APIs that we're using to ease the migration to a new one - so that we don't miss removing them once they're no longer needed - like this:
#warning("Old API, remove as soon as possible")
extension Navigator.Destination {
@available(*, deprecated, renamed: "bookDetails")
static func book(_ book: Book) -> Navigator.Destination {
return .bookDetails(book.metadata)
}
}
A good old TODO
or FIXME
combined with a linting tool such as SwiftLint can get the job done as well.
Conclusion
Taking a few extra steps to make API changes backward compatible might seem like an unnecessary effort at first, but it can often make larger refactors and API additions a lot quicker and easier to pull off. Especially in a larger team or when working on open source, avoiding breaking APIs with every change or addition can really help improve the workflow among developers and backward compatibility can also be a great communication tool as to why a specific change was made.
Not all changes warrant backward compatibility of course, and maintaining backward compatibility over a long period of time is also a complication in of its own. In my opinion, it's worth doing either to make larger changes or refactors easier and less risky, or when backward compatibility also adds convenience at a low cost. Like always, everything is a tradeoff, but the fewer disruptive changes we need to make the smoother our workflow usually becomes.
What do you think? Do you like backward compatibility, and is it something you usually think about when making larger changes? Let me know - along with your questions, comments or feedback - on Twitter @johnsundell.
By the way, this was my 75th weekly article about Swift! 🎉 I hope you enjoy reading these posts as much as I enjoy writing them. Now let's aim for 100! 😀
Thanks for reading! 🚀