Is there a way to deprecate public APIs for library clients only?

So, I have a library, which includes a public API for percent-encoding. The library comes with a bunch of "encode sets" (sets of characters which should be percent-encoded) as defined in the URL standard, and it is possible for users for create their own by conforming to a protocol.

The API looks like this:

extension StringProtocol {

  public func percentEncoded<EncodeSet: PercentEncodeSet>(
    as encodeSet: EncodeSet
  ) -> String {
    // ...
  }

  public func percentEncoded<EncodeSet: PercentEncodeSet>(
    as encodeSet: KeyPath<URLEncodeSet, EncodeSet>
  ) -> String {
    percentEncoded(as: URLEncodeSet()[keyPath: encodeSet])
  }
}

public struct URLEncodeSet {
  public var userInfo: UserInfo { ... }
}

Now, this might look a bit strange at first - I have one API which accepts an instance of a PercentEncodeSet, and another which accepts a KeyPath on an empty struct which serves as a namespace. The benefit of this is that it allows a more fluid API for well-known encode sets:

someString.percentEncoded(as: \.userInfo)

Without requiring the user to instantiate an object or write out type names such as URLEncodeSet.UserInfo.self.

In the mean time, SE-0299: Extending Static Member Lookup in Generic Contexts happened. The API I have is exactly the kind of "static member on protocol" API which the proposal intends to make easier. So instead of all of this awkward KeyPath stuff: I can simply write an extension on the protocol:

extension PercentEncodeSet where Self == URLEncodeSet.UserInfo {
  public static var userInfo: URLEncodeSet.UserInfo { ... }
}

And users can write:

someString.percentEncoded(as: .userInfo)

Without having to go through the KeyPath overload. Very cool!

Unfortuntately, this new syntax requires a Swift 5.5 compiler, and I'd like to support Swift 5.3+. So what I'd like to do is to include both, but steer users with a Swift 5.5 compiler towards the new syntax. It turns out that it is actually possible:

  1. Enclose the above PercentEncodeSet where Self == X extensions in a #if swift(>=5.5) block.

  2. Use @available to mark the KeyPath overloads as deprecated for users with Swift 5.5+ compiler:

    @available(swift, deprecated: 5.5, message: "Use static member syntax instead; e.g. percentEncoded(as: .userInfo)")
    public func percentEncoded<EncodeSet: PercentEncodeSet>(
      as encodeSet: KeyPath<URLEncodeSet, EncodeSet>
    ) -> String {
     percentEncoded(as: URLEncodeSet()[keyPath: encodeSet])
    }
    

But what happens is that now I'm getting a bunch of deprecation warnings when compiling with Swift 5.5! Not cool! I'd still like to use the KeyPath version internally, because I still need the ability to build with Swift 5.3, but I'd like clients to use the more modern syntax.

So this is my question: Is there a way to say "this API is deprecated for clients using the latest Swift compilers, but not for me, because I still need to work with older compilers"?

2 Likes

Obviously an actual attribute or other feature would be superior, but in the meantime the best I can think of would be to make the implementation method internal and undeprecated (and differentiated by e.g. an underscore), while having an equivalent (not underscored) forwarding public symbol marked deprecated, but make sure any internal call sites use the internal variant and not the public API.

3 Likes

So after working though this for a bit, I realised a couple of things:

  1. What I really want is to deprecate this API based on the client's minimum supported Swift language version.

  2. As it happens, package declarations include a swiftLanguageVersions property, which gets passed to the compiler. For some reason, this is an array rather than a minimum version, but whatever.

  3. I had forgotten to set this, and since I want my library to only use 5.3 language features (unless I opt-in to a newer version via #if swift), I set it to [.version("5.3")].

  4. swift build then correctly passes this to the compiler as -swift-version 5.3. Xcode doesn't, and passes -swift-version 5 instead. Because Xcode.

  5. Actually it doesn't even matter, because the swift compiler doesn't have a language mode 5.3:

    <unknown>:0: error: invalid value '5.3' in '-swift-version 5.3'
    <unknown>:0: note: valid arguments to '-swift-version' are '4', '4.2', '5'
    

The swift language mode properties are described by:

They're very short and, to be honest, not entirely helpful (it's pretty much "support the compiler's -swift-version argument", without a clear description of what that means). But there are hints that it was intended to allow what I'm trying to do:

This is important for packages which want to add support for a newer Swift language version but also want to retain compatibility with an older language and tools version [SE-0209]

So ultimately the conclusion I'm coming to is that SwiftPM is able to express what I want. I think the problem is that the compiler only knows about major Swift language versions (v4 vs v5, but not v5.3 vs v5.5). That means I can't deprecate something in a new minor version of the language (5.5) and keep compiling using an older minor version (5.3). All I can tell it is "build in v5 mode".

This is kind of problem. Swift 5.0 was released in March 2019, and that every feature since then has come in a minor language version. And once Swift 6 does come, all we'll be able to say is "build in v5 mode", without saying whether we mean 5.1, 5.3, 5.5, etc.

1 Like

Right, but then there's a problem if the client is another application/library in the same situation: building with a 5.5 compiler, but needing 5.3 compatibility. The problem I was having with the library is just a symptom of a larger problem.

We need to be able to tell the compiler precisely which version of the language we're targeting.