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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.