Supporting Older SDK Versions with Swift

It’s possible, but it’s a pain.

The Swift Package Manager has made it easier than ever to make a library with wide compatibility. By default, SPM packages even support Linux! However, it can be quite tricky to support different major iOS and macOS SDK versions. Swift provides a number of conditional compilation conditions that can be used to help. But, there is currently no way to determine what SDK you are building against.

Or is there?

The Initial Problem

The type NSTextLocation was introduced in iOS 15/macOS 12. Let’s say you want to add an extension on this type. Your first pass might look something like this:

#if os(macOS)
import AppKit
#elseif os(iOS) || os(tvOS)
import UIKit
#endif

@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
public extension NSTextLocation {
    ...
}

This is a pretty good start. You’ve got the conditional import statements, and you’ve used the @available directive to indicate the runtime availability. However, this code still has some compatibility issues.

Using @available

As written, this code will fail to build for watchOS. And, perhaps less obviously, will also fail to build on Linux. I think maintaining broad compatibility is a good general goal, so let’s try to do better.

You might be tempted, as I was at first, to try something like this:

@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
@available(watchOS, unavailable)
@available(Linux, unavailable)
public extension NSTextLocation {
    ...
}

It is true that this extension should be marked unavailable in these conditions. However, the thing to keep in mind is @available controls runtime availability. So, while these directives are appropriate, the reference to NSTextLocation still won’t be visibile at compile time on watchOS or Linux.

More Conditional Compilation

Just like with the import, we need to control what code the compiler sees, and we have to do that with conditional compilation statements. Luckily, this is a pretty straightforward thing to do.

#if os(macOS)
import AppKit
#elseif os(iOS) || os(tvOS)
import UIKit
#endif

#if os(macOS) || os(iOS) || os(tvOS)

@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
public extension NSTextLocation {
    ...
}

#endif

The fix is a little unsightly, perhaps, but it works. We’re now using conditional compilation to control the visibility of the extension itself. This is critical to prevent the compiler from running into the NSTextLocation type when it hasn’t been defined. You can always assume that conditional compilation around import statements means you need it other places too.

Older Xcode/SDK

At this point, I wouldn’t blame you for calling it a day. This code will compile on all platforms that Swift supports, even Windows! But, there’s a very subtle issue still lurking here - older SDKs.

This code will only compile with a version of Xcode that ships with the iOS 15/mac 12.0 SDK. As written, this will fail to build for Xcode 12. Typically, this isn’t a huge problem. Developers adopt new Xcode versions quickly…

…except when new SDKs are released in beta!

This phenomenon strikes around WWDC, and while rare, has also come up a few other times. If you maintain a library and want to start working with new types in the SDK, it will affect you. As far as I know, there is no nice way of handling this situation. Some might opt for a special branch of the code, and that may be the most sensible option. But, there is another tool we can use.

SDK conditionals

What we really want is to conditionalize our code not just on platform, but SDK version as well. The problem is, as of writing, Swift does not support that. Swift offers exactly five conditional compilation conditions: os(), arch(), swift(), compiler(), canImport(), and targetEnvironment().

We can use compiler() as a proxy for SDK version. It’s not pretty, but gets the job done. Here’s how:

#if (os(macOS) || os(iOS) || os(tvOS)) && compiler(>=5.5)

We’re making use of the fact that we know Xcode 13, the one with the SDK we need, shipped with Swift 5.5. The last version of Xcode 12 shipped with Swift 5.4.2.

Note that we don’t want to use swift(), as that value represents the language version. We need the compiler version here.

Also, don’t forget about that canImport() condition. It wasn’t helpful for us in this particular example, but it’s very handy when a new framework is introduced.

Wrapping Up

It’s surprisingly tricky to keep Swift packages compatible with all the platforms that the language actually supports. And, while I think it’s a nice thing to do, I’m not advocating for huge efforts. Just a little understanding of conditional compilation and some care can go a long way. Plus, it just feels good to see all those green checkboxes over at the Swift Package Index.

I won’t lie, I don’t love this compiler() trick. I’m always happy when I get to remove it. But, I think it’s a handy thing to keep in your toolbox, especially around WWDC season.

Mon, Feb 28, 2022 - Matt Massicotte

Previous: MetricKit Crash Reporting, Part 2
Next: MeterReporter: Lightweight MetricKit Diagnostics