Port `@targetName` to Scala 2

tl;dr to evolve APIs we need ways to break source-compatibility without breaking binary- and now also TASTy-compatibility. @targetName is an effective tool to do this but is not available in Scala 2, so cross-compiled libraries cannot use it.

Consider this not uncommon situation where we want to relax an implicit constraint for a public method in a library without breaking compatibility.

object Foo {
  // deprecated API
  def bar[F[_]](baz: Baz)(implicit F: Monad[F]): F[Qux] = ???

  // replacement API
  def bar[F[_]](baz: Baz)(implicit F: Functor[F]): F[Qux] = ???
}

Unfortunately simply adding a new overload will not work due to the call-site ambiguity because Monad[F] <: Functor[F].

Foo.bar[Option](baz)
// Ambiguous overload. The overloaded alternatives of method bar in object Foo with types
//  [F[_$4]](baz: Baz)(implicit F: Functor[F]): F[Qux]
//  [F[_$3]](baz: Baz)(implicit F: Monad[F]): F[Qux]
// both match type arguments [Option] and arguments ((baz : Baz))

My general approach for evolving APIs is finding ways to break source-compatibility of old APIs without breaking their binary-compatibility. This ensures that clients can cleanly compile against new (but otherwise source-compatible) versions of the API.

For example, we might achieve this by uncurrying the deprecated method.

object Foo {
  // deprecated API
  def bar[F[_]](baz: Baz, F: Monad[F]): F[Qux] = ???

  // replacement API
  def bar[F[_]](baz: Baz)(implicit F: Functor[F]): F[Qux] = ???
}

By changing the source-level signature of the old method, clients are now guaranteed to compile against the new method. Meanwhile the old method remains in the binary with an unchanged binary signature.

In Scala 3, besides binary-compatibility we also need to worry about TASTy-compatibility. I recently discovered that this sort of uncurrying actually breaks TASTy-compatibility.

Although on the one hand Scala 3 imposes additional constraints due to TASTy-compatibility, it also offers the @targetName annotation which is a useful tool for evolving APIs. For example:

object Foo {
  // deprecated API
  @targetName("bar")
  def deprecatedBar[F[_]](baz: Baz)(implicit F: Monad[F]): F[Qux] = ???

  // replacement API
  def bar[F[_]](baz: Baz)(implicit F: Functor[F]): F[Qux] = ???
}

By changing the source-level name of the old method, clients are now guaranteed to compile against the new method. Meanwhile the old method remains in binary and TASTy under its original name.

The good news is that there are strategies available for compatibly evolving APIs in both Scala 2 and Scala 3. Unfortunately, these strategies do not always overlap, which is a problem for cross-compiled libraries.

Porting the @targetName annotation to Scala 2 would close that gap (and also be highly useful in general).

Further reading: Discord discussion with @smarter about broken TASTy-compatibility in Cats 2.10.

5 Likes

I’m afraid @targetName("foo") def bar is not tasty compatible with def foo. Did tasty-mima suggest otherwise?

1 Like

Ha, of course it’s not :face_in_clouds: No, I considered checking it just in case but didn’t, well because I was lazy and it seemed reasonable and Guillaume didn’t mention that either :wink:

Ok. So then we are in direr need for a solution here than I realized. For my little case study above, is there a way to make it work without breaking TASTy compatibility?