Getting Started with Akka Typed Actors: Compile‑Time Safety for Messaging
Learn how Akka Typed Actors move message‑type checks to compile time, keeping runtime performance identical to classic actors while preventing protocol bugs.
21 Sept 2026, 00:23 UTC

The problem: untyped messages can slip through at runtime
When you build an Akka system with the classic untyped API, every ActorRef accepts Any. A typo in a message class or a mistaken tell call compiles fine, but at runtime you may see a ClassCastException or an unhandled message that silently drops. Detecting these bugs often requires extensive testing or production monitoring.
Thesis: Akka Typed Actors move protocol checks to compile time while keeping the same runtime performance
Akka’s typed API (akka.actor.typed) replaces the untyped ActorRef with a strongly‑typed ActorRef[Msg]. The compiler verifies that every sent message conforms to the actor’s declared protocol (Behavior[Msg]). Because the typed layer sits on top of the same untyped actor system, throughput and latency remain essentially unchanged, giving you safety without a performance penalty.
Worked example: a typed counter
We’ll define a simple counter protocol, implement it with a Behavior, spawn the actor, and exchange messages. The example shows both the compile‑time guarantee and how to interact with the actor from untyped code if needed.
1. Define the message protocol
// CounterProtocol.scala
sealed trait CounterMsg
final case class Increment(amount: Int) extends CounterMsg
final case class GetValue(replyTo: akka.actor.typed.ActorRef[Int]) extends CounterMsg
case object Stop extends CounterMsg
2. Implement the behavior
// Counter.scala
import akka.actor.typed.scaladsl.Behaviors
import akka.actor.typed.{ActorRef, Behavior}
object Counter {
def apply(): Behavior[CounterMsg] = {
def updated(value: Int): Behavior[CounterMsg] = Behaviors.receiveMessage {
case Increment(delta) =>
updated(value + delta)
case GetValue(replyTo) =>
replyTo ! value
Behaviors.same
case Stop =>
Behaviors.stopped
}
updated(0)
}
}
3. Spawn the typed actor and send messages
// Main.scala
import akka.actor.typed.{ActorSystem, Behavior}
import akka.actor.typed.scaladsl.Behaviors
object Main extends App {
val system: ActorSystem[Nothing] = ActorSystem(Behaviors.empty, "CounterSystem")
try {
val counter = system.systemActorOf(Counter(), "counter")
// Correct usage – compiles
counter ! Increment(5)
counter ! Increment(3)
// Request the current value
val reply = scala.concurrent.Promise[Int]()
counter ! GetValue(replyTo = akka.actor.typed.ActorRef. contramap[Int](reply.success))
// In a real app you would await the promise; here we just illustrate the call.
// Incorrect usage – does NOT compile:
// counter ! "not a CounterMsg" // <-- compile‑time error
counter ! Stop
} finally {
system.terminate()
}
}
If you attempt to send a string or any other type that does not extend CounterMsg, the Scala compiler will emit an error such as “type mismatch; found : String, required: CounterMsg”. This catches protocol violations before you even run the program.
4. Interoperating with untyped actors
Sometimes you need to pass a typed reference to an untyped API (e.g., Akka Persistence or Akka HTTP). You can obtain the underlying untyped reference safely:
val untypedRef: akka.actor.ActorRef = counter.toUntyped
// Now you can use untypedRef with classic Akka tools.
The conversion is explicit, reminding you that you are stepping outside the compile‑time safety boundary.
Trade‑offs and limitations
- Learning curve: Teams accustomed to imperative
receiveblocks must adapt to the functional style where each message handler returns the nextBehavior. This can feel unfamiliar at first. - Tooling gaps: Some Akka modules (e.g., Akka Persistence plugins, Akka HTTP routing) still expect untyped
ActorRef. You’ll need adapter layers or explicit.toUntypedcalls, which adds a small amount of boilerplate. - Debugging stack traces: Because the behavior chain is implicit, a stack trace may not show the exact
Behaviorinstance that processed a message. Enabling Akka’s debug logging (akka.loglevel = DEBUG) or usingActorContext.loghelps trace message flows.
Practical way to check the result: after running sbt run, observe that the program terminates without throwing ClassCastException or other runtime errors. If you deliberately comment out the correct Increment messages and send an illegal type, the build will fail, confirming the compile‑time guard.
Actionable closing
Start small: pick a self‑contained actor in your codebase, define its protocol as a sealed trait, and rewrite its receive as a Behavior. Use ActorSystem.spawn (or system.systemActorOf) to instantiate it, and replace any tell calls with the typed reference. Verify the build succeeds, then run the application to ensure behavior remains unchanged. Gradually expand to more actors, and use .toUntyped only where you must interface with legacy untyped components. This incremental approach gives you compile‑time safety while preserving the performance and ecosystem you already rely on.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.