Scala - Sealed Traits and Classes
Sealed traits restrict where subtypes can be defined. All implementations must exist in the same source file as the sealed trait declaration. This constraint enables powerful compile-time guarantees.
Key Insights
- Sealed traits and classes restrict inheritance to the same file, enabling exhaustive pattern matching that the compiler can verify at compile-time
- The compiler generates warnings when pattern matches are non-exhaustive, eliminating entire categories of runtime errors common in open hierarchies
- Sealed hierarchies model algebraic data types (ADTs) effectively, making them ideal for domain modeling, state machines, and protocol definitions
Understanding Sealed Traits
Sealed traits restrict where subtypes can be defined. All implementations must exist in the same source file as the sealed trait declaration. This constraint enables powerful compile-time guarantees.
sealed trait PaymentMethod
case class CreditCard(number: String, cvv: String) extends PaymentMethod
case class DebitCard(number: String, pin: String) extends PaymentMethod
case class PayPal(email: String) extends PaymentMethod
case object Cash extends PaymentMethod
The compiler knows every possible subtype of PaymentMethod. This knowledge enables exhaustiveness checking in pattern matching:
def processPayment(method: PaymentMethod): String = method match {
case CreditCard(number, _) => s"Processing credit card $number"
case DebitCard(number, _) => s"Processing debit card $number"
case PayPal(email) => s"Processing PayPal for $email"
case Cash => "Processing cash payment"
}
Remove any case and the compiler warns you. This eliminates the need for catch-all cases that hide bugs.
Exhaustiveness Checking in Action
The real power emerges when you modify sealed hierarchies. Add a new payment method:
sealed trait PaymentMethod
case class CreditCard(number: String, cvv: String) extends PaymentMethod
case class DebitCard(number: String, pin: String) extends PaymentMethod
case class PayPal(email: String) extends PaymentMethod
case class Cryptocurrency(wallet: String, currency: String) extends PaymentMethod
case object Cash extends PaymentMethod
Every pattern match across your codebase that doesn’t handle Cryptocurrency now generates a compiler warning:
warning: match may not be exhaustive.
It would fail on the following input: Cryptocurrency(_, _)
This compile-time safety prevents runtime MatchError exceptions. You find all affected code immediately, not in production.
Modeling State Machines
Sealed traits excel at representing finite state machines. Consider a connection state model:
sealed trait ConnectionState
case object Disconnected extends ConnectionState
case class Connecting(attempt: Int) extends ConnectionState
case class Connected(sessionId: String, since: Long) extends ConnectionState
case class Failed(reason: String, canRetry: Boolean) extends ConnectionState
class ConnectionManager {
private var state: ConnectionState = Disconnected
def transition(event: ConnectionEvent): Unit = {
state = (state, event) match {
case (Disconnected, Connect) =>
Connecting(1)
case (Connecting(attempt), ConnectionSucceeded(sessionId)) =>
Connected(sessionId, System.currentTimeMillis())
case (Connecting(attempt), ConnectionFailed(reason)) if attempt < 3 =>
Connecting(attempt + 1)
case (Connecting(attempt), ConnectionFailed(reason)) =>
Failed(reason, canRetry = false)
case (Connected(_, _), Disconnect) =>
Disconnected
case (Connected(sessionId, _), ConnectionLost) =>
Failed("Connection lost", canRetry = true)
case (Failed(_, true), Connect) =>
Connecting(1)
case _ => state // Invalid transitions maintain current state
}
}
}
sealed trait ConnectionEvent
case object Connect extends ConnectionEvent
case object Disconnect extends ConnectionEvent
case class ConnectionSucceeded(sessionId: String) extends ConnectionEvent
case class ConnectionFailed(reason: String) extends ConnectionEvent
case object ConnectionLost extends ConnectionEvent
The sealed hierarchy ensures you handle all valid state transitions. Adding new states or events triggers compiler warnings wherever transitions aren’t handled.
Domain Modeling with ADTs
Algebraic data types represent domain concepts precisely. Model a result type that replaces exception throwing:
sealed trait Result[+A]
case class Success[A](value: A) extends Result[A]
case class Failure(error: String, cause: Option[Throwable] = None) extends Result[Nothing]
object Result {
def apply[A](f: => A): Result[A] = {
try {
Success(f)
} catch {
case e: Exception => Failure(e.getMessage, Some(e))
}
}
implicit class ResultOps[A](result: Result[A]) {
def map[B](f: A => B): Result[B] = result match {
case Success(value) => Success(f(value))
case Failure(error, cause) => Failure(error, cause)
}
def flatMap[B](f: A => Result[B]): Result[B] = result match {
case Success(value) => f(value)
case Failure(error, cause) => Failure(error, cause)
}
def getOrElse[B >: A](default: => B): B = result match {
case Success(value) => value
case Failure(_, _) => default
}
}
}
Use it to chain operations without exception handling:
def parseUser(json: String): Result[User] = {
for {
parsed <- Result(parse(json))
id <- extractField(parsed, "id")
email <- extractField(parsed, "email")
user <- validateUser(id, email)
} yield user
}
def extractField(json: JValue, field: String): Result[String] = {
json \ field match {
case JString(s) => Success(s)
case _ => Failure(s"Missing or invalid field: $field")
}
}
Sealed Classes vs Sealed Traits
Sealed classes work identically to sealed traits but cannot be extended by traits, only by classes:
sealed abstract class Tree[+A]
case class Leaf[A](value: A) extends Tree[A]
case class Branch[A](left: Tree[A], right: Tree[A]) extends Tree[A]
case object Empty extends Tree[Nothing]
def depth[A](tree: Tree[A]): Int = tree match {
case Leaf(_) => 1
case Branch(left, right) => 1 + math.max(depth(left), depth(right))
case Empty => 0
}
Use sealed classes when you want to provide common concrete implementation or restrict the inheritance hierarchy more strictly. Use sealed traits when you need multiple inheritance or want to mix in behavior.
Nested Sealed Hierarchies
Complex domains benefit from nested sealed hierarchies:
sealed trait Expression
sealed trait Literal extends Expression
case class IntLiteral(value: Int) extends Literal
case class StringLiteral(value: String) extends Literal
case class BoolLiteral(value: Boolean) extends Literal
sealed trait BinaryOp extends Expression
case class Add(left: Expression, right: Expression) extends BinaryOp
case class Subtract(left: Expression, right: Expression) extends BinaryOp
case class Multiply(left: Expression, right: Expression) extends BinaryOp
sealed trait UnaryOp extends Expression
case class Negate(expr: Expression) extends UnaryOp
case class Not(expr: Expression) extends UnaryOp
def evaluate(expr: Expression): Any = expr match {
case IntLiteral(v) => v
case StringLiteral(v) => v
case BoolLiteral(v) => v
case Add(l, r) => evaluate(l).asInstanceOf[Int] + evaluate(r).asInstanceOf[Int]
case Subtract(l, r) => evaluate(l).asInstanceOf[Int] - evaluate(r).asInstanceOf[Int]
case Multiply(l, r) => evaluate(l).asInstanceOf[Int] * evaluate(r).asInstanceOf[Int]
case Negate(e) => -evaluate(e).asInstanceOf[Int]
case Not(e) => !evaluate(e).asInstanceOf[Boolean]
}
The compiler verifies you handle every expression type. Intermediate sealed traits like Literal and BinaryOp let you match at different granularities.
Performance Considerations
Sealed hierarchies with case classes generate efficient bytecode. Pattern matching compiles to tableswitch or lookupswitch instructions when possible:
sealed trait Status
case object Pending extends Status
case object Processing extends Status
case object Complete extends Status
case object Failed extends Status
// Compiles to efficient switch statement
def statusCode(status: Status): Int = status match {
case Pending => 100
case Processing => 200
case Complete => 300
case Failed => 400
}
Case objects are singletons, avoiding allocation overhead. Case classes benefit from compiler-generated equals, hashCode, and copy methods.
Best Practices
Keep sealed hierarchies in dedicated files. This makes the complete type set immediately visible:
// PaymentMethod.scala
sealed trait PaymentMethod
case class CreditCard(number: String, cvv: String) extends PaymentMethod
case class DebitCard(number: String, pin: String) extends PaymentMethod
case class PayPal(email: String) extends PaymentMethod
case object Cash extends PaymentMethod
Enable fatal warnings to enforce exhaustiveness:
// build.sbt
scalacOptions += "-Xfatal-warnings"
This converts exhaustiveness warnings into compilation errors, preventing incomplete pattern matches from reaching production.
Use sealed traits for public APIs where you control all implementations. This guarantees API stability—clients cannot add new subtypes that break your assumptions.
Avoid sealed hierarchies when extensibility is required. If third-party code needs to add implementations, use regular traits with type classes for behavior dispatch instead.