Safely supporting new versions of Swift

Over the past two years, I’ve been lucky enough to have lead a few Swift version updates at work. Because Swift has changed dramatically over the past few years new versions have resulted in a lot of churn, so I’ve tried three different ways of migrating a large Swift codebase to a new version.

The first way; In one big go

I bet that most people do it this way; just checkout a new branch in git and make all the changes until it compiles. We did this one with Swift 3.0 and we had a lot of challenges. Even though conditional compilation of source using#if swift had been available since Swift 2.2 (SE-0020), we couldn’t use it as the number of changes we had to make numbered in the thousands. When we pushed the changes we had a broken master for a few days. And then master was finally building successfully, we still had runtime crashes. Not a great situation to be in.

And this strategy only works if you are in a small team or the codebase is tiny, so that the changes are relatively easy to review in a Pull Request. If you have a large team or lots of Swift, you probably don’t want to just push a giant commit that updates to a new version of Swift, and if you made a PR from that commit your fellow team members will probably not read it. We want our engineers to review the changes!

The second way; Using conditional compilation

The other way you can migrate to a new version of Swift is by conditionally compiling code based on Swift version. For example, Xcode 10.2 ships with the Swift 5 compiler. But it supports Swift 4, 4.2 and 5.0. You might imagine this means that you won’t have to make any changes as long as you stick with 4 or 4.2, but in practice this isn’t the case. Because the new version of Xcode also comes with a new compiler, you’ll see that a lot of new warnings and maybe some errors from that new compiler version will appear, even though you’re still not using the new version of Swift.

New versions of the Swift compiler tend to support multiple versions of Swift but they will come with newer diagnostics and warnings that Apple wants to make you aware of. When that happens, you can use conditional compilation to compile code both ways:

let str = "str"

In the above example, we are forced to update our code because a warning appears with Xcode 10.2 that init(encodedOffset:) on String.Index has been deprecated in favour of init(utf16Offset:in:). Because the new initializer is not available in previous versions of the Swift compiler, we can’t just commit the change and still support the previous version of Xcode, because Xcode 10.1 shipped with the Swift 4.2 compiler (or 4.2.1 to be precise). Therefore, we conditionally compile the code so that a different initializer is called depending on what version of Swift you are compiling for. The Swift 5 compiler in Xcode 10.2 actually ships with version 4.2.2 of Swift 4.2.

You can already see that it’s weird that you need to know how the compiler and the version work in tandem in order to do the above change, and you also need to know what the new version of Swift is, right down to the the last digit. Thankfully in SE-0212, Apple added a better way with the new compiler directive:

#if compiler(>=5.0)
let i = String.Index(utf16Offset: 1, in: str)
let i = String.Index(encodedOffset: 1)

There’s something special going on here. Even though we’re making a change in our 4.2 codebase, we’re saying ‘when we compile with the 5.0 compiler, do this, else do that’. Now that they’ve decoupled compiler version from swift version it’s easier to make a change that is for the new compiler for all versions that compiler supports. The line wrapped by the above #if will be compiled for 4.2 and 5.0, but only when the 5.0 compiler is used.

But let me show you where the above strategy fails. If you have to commit a lot of conditionally compiled changes, all your team members have to remember if they make a change inside the #else , they also have to make it inside the #if , because you can’t upgrade to the new Xcode version (and Swift compiler) until you’ve migrated your entire codebase. This might take you weeks, and while you’re working on it the #if cases of your conditionally compiled code might start to drift away from their corresponding #else cases:

#if compiler(>=5.0)
let i = String.Index(utf16Offset: 1, in: str)
let i = String.Index(encodedOffset: 0) // changed offset to zero

It might sound farfetched, but I promise you it’s not. Because the code inside the #if won’t compile with the previous version of the compiler, if a developer on your team wants to make a change inside the #if they should install that version of Xcode, switch to it using sudo xcode-select -s , and then compile again. You shouldn’t allow any change unless you compile it twice — once for each compiler version—and this wastes valuable developer time. Sure you could leverage your Continuous Integration environment to compile the code twice — once with each of the two Xcode versions—but that’s only if your CI supports multiple Xcode versions.

As for all the typing that the above strategy requires, I made a quick hacky Xcode Source Editor extension that helps with this part:

Image for post
Image for post

But once you’ve landed all the conditionally compiled code and you’ve updated the swift version in your project settings, you have to go back and delete all the dead #else cases. Who wants to do that?!

The third way; using multiple Swift versions at the same time

This approach comes with a caveat; which is that you need to separate your code into modules (‘frameworks’), so that your app looks more like this:

Image for post
Image for post
please excuse my terrible graphing skills

…where all the code is compartmentalized into individual modules, and not like this:

Image for post
Image for post
please excuse my terrible people skills

Because if you take the time to break your app up into smaller frameworks you can:

  1. Compile several frameworks at the same time, potentially improving compiler performance over many threads (though batch mode kinda solved this thank you Apple!)
  2. Make sure that internal access control levels actually mean that only code inside the same module can see that symbol
  3. Separate your code so that it’s cleaner and better architected
  4. Mix and match different versions of Swift (the feature I’m about to show you)
  5. Brag to your coworkers that our app has lots of tiny frameworks endlessly until they ostracize you

So let’s talk about that mix and match step. In 2017, the Swift team gave us a new compiler flag called -swift-version (SR-2582). The concept of a language ‘mode’ introduced in Xcode 9 with Swift 4 meant that you can finally mix–and–match code for different swift versions as long as the compiler supports those versions. In short, you can do this in your 4.2–mode module:

/* Module A compiled in 4.2 mode */
let index = "string".index(of: "n")

While doing this in a different 5.0–mode module:

/* Module B compiled in 5.0 mode */
let index = "string".firstIndex(of: "n")

So how do you accomplish this? Again, you need to modularize your code, so that’s a big first step if you haven’t been doing that already. Then, you can just set the version for each framework individually in Xcode:

Image for post
Image for post
Module A compiling in 4.2 mode
Image for post
Image for post
Module B compiling in 5.0 mode

I’ve created a sample project on GitHub demonstrating all three methods.

In summary, which approach you pick really depends on the structure of your project, the size of your team, your tolerance for lost developer productivity, and how risky you want to be with your upgrade. But if like me you have to balance being on the latest Swift version and keeping the risk low, the best approach is I think the third one.

Let me know if you’ve got any questions!

Written by

iOS Software Engineer at Uber ★

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store