Build-time SDK availability check

Every year around this time, I find myself needing to write code that compiles differently based on the availability of a particular SDK version. Unfortunately, there's no way to do that. My goal is to be able to write code that uses features from the new iOS or Mac SDKs if they are available.

I wrote about this two years ago, and everyone seemed to agree that it was necessary then. I think we just need someone to actually implement it.

TL;DR: We really need something like #if sdk() or #if moduleVersion(). This comes up every year.


For example, I would like to be able to write code like this, and have it compile with both Xcode 13 and 14:

// This code may build on mac, iOS, and watchOS.
// The iOS 16 and watchOS 9 SDKs are not yet
// released, so most of the team is still building
// with iOS 15 and watchOS 8 SDKs. We want the
// code to compile for everyone.
func widgetView(for family: WidgetFamily) -> some View {
    switch family {
    case .systemSmall: // ...
    case .systemMedium: // ...
    case .systemLarge: // ...

    #if sdk(iOS 15)
    case .systemExtraLarge: // ...  [case 1]
    #endif

    // If we're building with the iOS 16 SDK, handle the new
    // widget size families
    #if sdk(iOS 16, watchOS 9)
    case .accessoryCircular: // ...  [case 2]
    case .accessoryRectangular: // ...
    case .accessoryInline: // ...
    #endif

    // This one is watch-only
    #if sdk(watchOS 9)
    case .accessoryCorner: // ... [case 3]
    #endif

    // This doesn't actually exist; only for illustrative purposes
    #if sdk(macOS 13)
    case .someMacOnlyVariant: // [case 4] 
    #endif

    @unknown default: // ...
    }
}

Unfortunately, the above code does not work, because #if sdk() doesn't exist. We have to work around it with various ugly hacks.


For the first case, since it's in a stable release, we can just use #if os():

#if os(iOS)
case .systemExtraLarge: // ...  [case 1]
#endif

For the second case, though, it's more ugly. We have to use a combination of #if os() and #if compiler():

// The iOS 16 SDK ships with Xcode 14, which 
// contains the Swift 5.7 compiler. So we're 
// conflating the compiler version with the 
// SDK version
#if compiler(>=5.7) 
#if os(iOS) || os(watchOS)
case .accessoryCircular: // ...  [case 2]
case .accessoryRectangular: // ...
case .accessoryInline: // ...
#endif
#endif

The third case is similar; we have to use both #if compiler() and #if os() to only build on watchOS:

#if compiler(>=5.7) 
#if os(watchOS)
case .accessoryCorner: // ...  [case 3]
#endif
#endif

The fourth case gets especially tricky. At first glance, it looks the same; it's just a Mac-specific variant. However, historically the macOS SDK has usually shipped about a month after the corresponding iOS SDK. That means that this code would work during the beta period:

#if compiler(>=5.7) 
#if os(macOS)
case .someMacOnlyVariant: // [case 4] 
#endif
#endif

However, once Xcode 14 is released, this code actually fails to build! This is because even though the Xcode 14 betas included the macOS 13 SDK, if Apple follows their usual pattern, the final release of Xcode 14 will not include the macOS 13 SDK. Instead, that will be deferred to an Xcode 14.1 beta.

That makes things difficult, because now we can no longer use #if compiler(>=5.7) as a proxy for SDK availability, because both Xcode 14.0 and 14.1 will be using Swift 5.7. At that point, the only way we have to manage this is to rely on #if canImport(), because that is SDK-dependent. However, WidgetFamily here doesn't actually need to import any new frameworks, so we end up with a very confusing check:

// AppIntents is an unrelated framework also
// introduced in the macOS 13 SDK. We can use
// it as a proxy for "is the macOS 13 SDK 
// available"
#if canImport(AppIntents) 
#if os(macOS)
case .someMacOnlyVariant: // [case 4] 
#endif
#endif

We could use the #if canImport(AppIntents) trick in other places too, since we have evidence that the compiler version is not always coupled to the SDK version. This leaves us with this as our final body:

// This code may build on mac, iOS, and watchOS.
// The iOS 16 and watchOS 9 SDKs are not yet
// released, so most of the team is still building
// with iOS 15 and watchOS 8 SDKs. We want the
// code to compile for everyone.
func widgetView(for family: WidgetFamily) -> some View {
  switch family {
    case .systemSmall: // ...
    case .systemMedium: // ...
    case .systemLarge: // ...

    // By luck, AppIntents is available across macOS, iOS, and watchOS,
    // so we can use this single check for all the following cases
    #if canImport(AppIntents)

    #if os(iOS) || os(watchOS)
    case .accessoryCircular: // ...  [case 2]
    case .accessoryRectangular: // ...
    case .accessoryInline: // ...
    #endif

    // This one is watch-only
    #if os(watchOS)
    case .accessoryCorner: // ... [case 3]
    #endif

    // This doesn't actually exist; only for illustrative purposes
    #if os(macOS)
    case .someMacOnlyVariant: // [case 4] 
    #endif

    #endif

    @unknown default: // ...
}

This works, but that's a very load-bearing #if canImport(AppIntents), and it only works because Apple happened to introduce an unrelated framework in this same SDK release.


We need something that lets us more specifically say "this code should only build if this particular version of the SDK is available." It seems like most everyone agrees this would be useful (based on the previous thread); it sounds like we just need an actual proposal and then someone willing to implement it.

20 Likes

The need for an official solution to this problem makes sense to me. The main design question I have is about the granularity of the query. I think that a design like the one you proposed where the query matches the OS version aligned with the SDK is the most practical solution but it somewhat narrow in its utility.

As a starting point for exploring some of the alternatives, there's actually already an underscored variant of #if canImport() that takes a module version, and it was designed to be used in situations precisely like the one you detailed in the post:

    // Suppose WidgetKit-1.2.3 is user-module-version of WidgetKit in one
    // of the betas that introduced these new APIs.
    #if canImport(WidgetKit, _version: "1.2.3")
    // ... handle other cases introduced in iOS 16 aligned SDKs
    
    #if os(macOS)
    // handle macOS specific cases introduced in iOS 16 aligned SDKs
    case .someMacOnlyVariant: // [case 4]
    #endif

    #endif // canImport(WidgetKit, _version: "1.2.3")

This is not especially ergonomic to use because you need to scrape the the user-module-version of the relevant framework out of the SDK in order to determine the version to put in the query (the version can be found at the top of one of the framework's .swiftinterface files). The design does have a few nice properties, though:

  • If the framework and the APIs in question are cross platform, then you can write a single query to determine the build time availability of the API across multiple platforms because module versions tend to be aligned across the aligned platform specific SDKs.
  • It allows you to gracefully handle things like APIs introduced midway through the betas even though the overall system/SDK version number hasn't changed.
  • It works for any Swift module that has an embedded user-module-version, regardless of whether the module is distributed with an SDK that is legible to the compiler.

We've also discussed an even finer grained version of this in another evolution thread. There the idea was to query directly for the presence of an individual declaration, and it might look something like this for the example in the OP:

    #if has(WidgetKit.WidgetFamily.someMacOnlyVariant)
    case .someMacOnlyVariant: // [case 4]
    #endif

This is a potentially more verbose, but also more powerful query. Unfortunately I think we came to the conclusion that this would be prohibitively difficult to implement since it needs to be evaluated during parsing, before name lookups can be performed.

Of these two alternatives, I think that an official version #if canImport(Foo, _version: "1.2.3") has the most promise because it is flexible enough to work in a broader ecosystem. We might be able to overcome the current awkwardness of relying on -user-module-version being difficult to find by enhancing tooling so that it's easier to inspect.

3 Likes

I do like the idea of tying it to #if canImport(). Using a user module version may be a little bit opaque; for example, the WidgetKit user-module-version in the latest Xcode beta is "277". It's not obvious what that means, but if that were exposed somewhere more visible, maybe it would be fine.

3 Likes

(May be late for a reply), but

I highly agree with bj here, framework versions are often not exposed and most developers just don’t know them, can you name the UIKit framework version for iOS 16 beta? Which is why I agree more with #if sdk or #if sdkAvailable

In this thread it was brought up that there may be changes in the Swift Standard Library that are based on SDK. It's not clear what you'd check for in an canImport in this case:

#if canImport(???)
// Primary Associated Types on Collection are only declared in the iOS 16 and macOS 13 SDKs
func demo(x: some Collection<Int>) { ... }
#else
func demo<T>(x: T) where T: Collection, T.Element == Int
#endif
1 Like

The module in this case would be Swift.

2 Likes