Akka typed actor messageAdapter bug

Below I have a minimal repro of an issue I’m having with the messageAdapter pattern in Akka Typed. When you run it, it will print out the log messages below, which breaks the defined Message types.

As far as I can tell, messageAdapter creates new adapters when you call it that’s conceptually something like a Map[Class[U], U => T] so you can look up the conversion function when a certain message type comes in, but to get the Class[U] it uses a ClassTag , which is a runtime reflection mechanism that suffers from type erasure.

So you have a Seq -> (Seq => Msg2) overriding the previously registered adapter Seq -> (Seq => Msg1) , instead of Seq[Int] => (Seq[Int] => Msg1) etc. Scala does have TypeTag , which does not suffer from type erasure (slightly different API, but conceptually similar), but it’s a Scala compiler specific thing of which Java has no equivalent. I’m guessing this is a known problem whose source is Java interop.

Are there any good workarounds here? I know you can do something like Behaviors.receive[Seq[Int] => Unit] and actorA ! (msg => context.self ! Msg1(msg)) which solves the problem, but feels hacky. I’ve also thought about writing something along the lines of my own MessageAdapter class, but a naive implementation needs to carry both the source and destination types, which is the problem the messageAdapter existed to solve in the first place.

[2023-06-14 16:26:54,814] [INFO] [Main$] [] [Guardian-akka.actor.default-dispatcher-3] - Msg2 Some(1) MDC: {akkaAddress=akka://Guardian, akkaSource=akka://Guardian/user, sourceActorSystem=Guardian}
[2023-06-14 16:26:54,816] [INFO] [Main$] [] [Guardian-akka.actor.default-dispatcher-3] - Msg2 Some(a) MDC: {akkaAddress=akka://Guardian, akkaSource=akka://Guardian/user, sourceActorSystem=Guardian}
object Main extends App {
  sealed trait Message
  object Message {
    case class Msg1(seq: Option[Int]) extends Message
    case class Msg2(seq: Option[String]) extends Message
  }

  val guardian = Behaviors.setup[Message] { context =>
    val actorA = context.spawn(
      Behaviors.receive[ActorRef[Option[Int]]] { case (_, replyTo) =>
        replyTo ! Some(1)
        Behaviors.stopped
      },
      "ActorA"
    )

    val actorB = context.spawn(
      Behaviors.receive[ActorRef[Option[String]]] { case (_, replyTo) =>
        replyTo ! Some("a")
        Behaviors.stopped
      },
      "ActorB"
    )

    actorA ! context.messageAdapter(Msg1)
    actorB ! context.messageAdapter(Msg2)

    Behaviors.receive[Message] { case (context, msg) =>
      msg match {
        case Msg1(opt: Option[Int]) =>
          context.log.info(s"Msg1 $opt")
        case Msg2(opt: Option[String]) =>
          context.log.info(s"Msg2 $opt")
      }
      Behaviors.same
    }
  }

  val actorSystem = ActorSystem(guardian, "Guardian")
}

For poor lost souls who stumble across this in the future, this is the workaround we developed.

object MessageAdapter {
  def apply[T, U](ref: ActorRef[T], f: U => T): MessageAdapter[U] =
    new MessageAdapter[U](msg => ref ! f(msg), ref.narrow)
​
  implicit class FromActorRef[T](ref: ActorRef[T]) {
    def adapter[U](f: U => T): MessageAdapter[U] = MessageAdapter(ref, f)
    def adapter: MessageAdapter[T] = MessageAdapter(ref, identity)
  }
}
​
class MessageAdapter[U] private (
    mapAndTell: U => Unit,
    ref: ActorRef[Nothing]
) {
  def tell(msg: U): Unit = mapAndTell(msg)
  def !(msg: U): Unit = mapAndTell(msg) // scalastyle:ignore
  def watch: ActorRef[Nothing] = ref
}```

Note that such an adapter, if the adaptation function throws, will throw in the sending site and not on the receiving side, which may be a surprise. Maybe not a big problem since you will also have a specific non ActorRef type that the sender will need to know about.

For one-off interactions with another actor context.ask will something like this, but do the conversion on receiving the message to adapt, but as you noted, the explicit message adapter for things like subscribing to a stream of messages or handing to another actor to use many times without knowing of the subscribing actors protocol, that is a bit tricky if you want to send generic messages.

As always use cases may differ in specific ways, but I’d like to add the general recommendation of using concrete message protocols without generics wherever possible. It solves the problem with erasure and adds useful details about what a request or reply means when you or someone else needs to understand the code in the future.