Scala Programming Guidesscala-oopscala-type-system

Case Classes, Sealed Hierarchies, and ADTs | Scala Programming Guide

By Dmitri Meshin
Picture of the author
Published on
Case Classes, Sealed Hierarchies, and ADTs - Scala Programming Guide

Case Classes: What You Get for Free

Case classes are the workhorse of Scala. When you declare a case class, the compiler generates boilerplate automatically. This includes an apply() factory method so you don't need new, automatic equals() and hashCode() based on fields, a meaningful toString(), automatic pattern matching support via unapply(), and a copy() method for creating modified versions. This is a tremendous amount of functionality for two keywords: case class. Understanding what you get for free is essential, because case classes are used everywhere in Scala—they're the default way to structure data.

// Define a case class
case class Point(x: Double, y: Double)

// You automatically get:
// 1. An apply() factory method (no 'new' needed)
val p1 = Point(3.0, 4.0)

// 2. equals() and hashCode() based on constructor params
val p2 = Point(3.0, 4.0)
val p3 = Point(5.0, 6.0)
println(p1 == p2)  // true
println(p1 == p3)  // false

// 3. toString() that shows the constructor call
println(p1)  // Point(3.0,4.0)

// 4. Automatic pattern matching (unapply)
val Point(px, py) = p1
println(s"x=$px, y=$py")

// 5. copy() method for creating modified versions
val p4 = p1.copy(x = 10.0)
println(p4)  // Point(10.0,4.0)

// Prove it: they work in collections
val points = Set(Point(0, 0), Point(1, 1), p1, p2)
println(points.size)  // 3, not 4 (p1 and p2 are equal)

// And as map keys
val pointMap = Map(
  p1 -> "first",
  p3 -> "second"
)
println(pointMap(p1))  // "first"

The automatic equals() and hashCode() implementations are critical. They're based on the constructor parameters, which means two Points with the same coordinates are considered equal. This is perfect for domain objects, where equality should mean "same values," not "same object." This enables using case classes as set elements and map keys reliably—something you can't easily do with regular classes.

A more realistic example in a game inventory demonstrates how case classes scale to real applications:

// Game items with properties
case class Item(
  id: String,
  name: String,
  rarity: String,  // "common", "rare", "epic", "legendary"
  weight: Double,
  value: Int
) {
  // You can add methods to case classes
  def isValuable: Boolean = value > 1000
  def displayInfo: String = s"$name (${rarity})"
}

// Inventory that tracks items
class GameInventory {
  private var items = List[Item]()

  def addItem(item: Item): Unit = items :+= item

  def findByRarity(rarity: String): List[Item] =
    items.filter(_.rarity == rarity)

  def totalValue: Int = items.map(_.value).sum

  // Pattern matching on case classes
  def describeLoot(): Unit = {
    items.foreach { item =>
      item match {
        case Item(_, name, "legendary", _, value) if value > 5000 =>
          println(s"★★★ JACKPOT: $name is worth $value gold! ★★★")

        case Item(_, name, rarity, weight, value) if weight > 50 =>
          println(s"⚠️ Heavy $name ($rarity) - only worth $value")

        case Item(id, name, rarity, _, value) =>
          println(s"[$id] $name ($rarity) - $value gold")
      }
    }
  }

  // copy() method in action
  def upgradeLegendary(item: Item): Item = {
    if (item.rarity == "legendary") {
      item.copy(value = (item.value * 1.5).toInt)
    } else item
  }
}

val inv = new GameInventory
inv.addItem(Item("i1", "Iron Sword", "common", 5.0, 50))
inv.addItem(Item("i2", "Excalibur", "legendary", 3.0, 10000))
inv.addItem(Item("i3", "Heavy Shield", "rare", 60.0, 300))

inv.describeLoot()
println(s"Total inventory value: ${inv.totalValue}")

Notice how naturally pattern matching works with case classes—the compiler generates the unapply method, so you don't have to. The copy() method is invaluable for immutability: instead of modifying an item in place, you call copy() with the fields you want to change, getting a new item back. This supports a functional style where data is immutable and transformations create new objects.

Case Objects

Case objects are case classes with no constructor parameters. Perfect for singletons. Case objects are to objects what case classes are to classes—they get all the auto-generated goodness. This makes them ideal for representing fixed values in sealed hierarchies, like Status enums or single-instance commands. A case object automatically has proper equals, hashCode, and toString implementations, making it work seamlessly in pattern matching and as set elements.

// Sealed hierarchy with case objects
sealed trait Color
case object Red extends Color
case object Green extends Color
case object Blue extends Color

// Pattern matching with case objects
def trafficLight(color: Color): String = color match {
  case Red => "Stop"
  case Green => "Go"
  case Blue => "??? (not a standard traffic light)"
}

println(trafficLight(Red))

// Case objects as values
val colors: List[Color] = List(Red, Green, Blue, Red, Green)
val uniqueColors = colors.distinct
println(s"Unique colors: ${uniqueColors.length}")

// Case objects work as enum-like values
sealed trait Status
case object Active extends Status
case object Inactive extends Status
case object Suspended extends Status

case class User(name: String, status: Status)

def userSummary(user: User): String = user match {
  case User(name, Active) => s"$name is active"
  case User(name, Inactive) => s"$name hasn't logged in"
  case User(name, Suspended) => s"$name is banned"
}

val alice = User("Alice", Active)
val bob = User("Bob", Suspended)

println(userSummary(alice))
println(userSummary(bob))

Case objects are perfect for representing fixed choices. Unlike case classes with no parameters (which would still be instantiable as new objects), case objects are true singletons—Red is always the same object. This makes them ideal for enum-like constructs and for representing values that have no internal state.

Sealed Traits/Classes: Closed Type Hierarchies

Sealed types restrict implementations to those defined in the same file or same package. This enables exhaustive pattern matching. Sealing a type hierarchy is one of the most powerful tools in Scala for domain modeling. It tells the compiler "these are all the possible types; enforce that anyone pattern matching on this hierarchy covers all cases." This is extraordinarily valuable because it catches logic errors at compile time. If you add a new status type (like "Archived"), every pattern match that handles Status will fail to compile, forcing you to consider how to handle the new case. Without sealing, you'd find out about missing cases at runtime, after users encounter them.

// A sealed hierarchy for a command system
sealed trait Command {
  def execute(): String
}

case class CreateUser(name: String, email: String) extends Command {
  def execute(): String = s"Created user $name <$email>"
}

case class UpdateUser(id: String, newEmail: String) extends Command {
  def execute(): String = s"Updated user $id to $newEmail"
}

case class DeleteUser(id: String) extends Command {
  def execute(): String = s"Deleted user $id"
}

case object ListUsers extends Command {
  def execute(): String = "Listing all users"
}

// CommandDispatcher can handle every possible command
object CommandDispatcher {
  def dispatch(cmd: Command): String = cmd match {
    case CreateUser(name, email) =>
      s"Creating: $name <$email>"

    case UpdateUser(id, email) =>
      s"Updating user $id with email $email"

    case DeleteUser(id) =>
      s"Deleting user $id (are you sure?)"

    case ListUsers =>
      "Retrieving user list"

    // No default case needed! Compiler ensures all cases covered
  }
}

val commands: List[Command] = List(
  CreateUser("Alice", "alice@example.com"),
  UpdateUser("u1", "newemail@example.com"),
  DeleteUser("u2"),
  ListUsers
)

commands.foreach(cmd => println(CommandDispatcher.dispatch(cmd)))

The sealing prevents anyone else from extending Command. Try adding this elsewhere and you get a compile error:

// This won't compile: Command is sealed
// case class CustomCommand(...) extends Command

Sealed hierarchies are the foundation of algebraic data types, the subject we'll explore next.

Algebraic Data Types: Sum Types and Product Types

Algebraic Data Types (ADTs) are built from sums (OR) and products (AND). This section is critical because ADTs are how you express domain models mathematically. Product types combine multiple types using AND: a Player is a name AND a level AND items. Sum types represent alternatives using OR: a Result is either Success OR Failure. Combining these creates powerful, expressive domain models where the type system enforces structure and exhaustiveness.

Product types combine multiple types:

// A product type: a Player is ALL of (name AND level AND items)
case class Player(name: String, level: Int, items: List[String])

Sum types represent alternatives:

// A sum type: a Result is EITHER Success OR Failure
sealed trait Result[T]
case class Success[T](value: T) extends Result[T]
case class Failure[T](error: String) extends Result[T]

Combining them creates powerful domain models:

// A rich example: a payment processing system

// Sum type: represents different payment states
sealed trait PaymentState

case class Pending(orderId: String) extends PaymentState
case class Authorized(orderId: String, authCode: String, amount: Double) extends PaymentState
case class Captured(orderId: String, transactionId: String) extends PaymentState
case class Failed(orderId: String, reason: String) extends PaymentState

// Product type: the payment itself combines multiple properties
case class Payment(
  id: String,
  state: PaymentState,      // This is a sum type
  timestamp: Long,
  metadata: Map[String, String]  // Product of String -> String
)

// Process payments based on their state
def processPayment(payment: Payment): String = payment.state match {
  case Pending(orderId) =>
    s"Payment ${payment.id} for order $orderId is pending authorization"

  case Authorized(orderId, code, amount) =>
    s"Payment authorized for $amount (auth code: $code). Ready to capture."

  case Captured(orderId, txId) =>
    s"Payment captured. Transaction: $txId"

  case Failed(orderId, reason) =>
    s"Payment failed: $reason"
}

val payment1 = Payment(
  "p1",
  Pending("order123"),
  System.currentTimeMillis(),
  Map("customer" -> "alice")
)

val payment2 = Payment(
  "p2",
  Authorized("order124", "AUTH456", 99.99),
  System.currentTimeMillis(),
  Map("customer" -> "bob")
)

println(processPayment(payment1))
println(processPayment(payment2))

The power of ADTs is that they make your domain model's structure explicit in the type system. A Payment is always a specific PaymentState—it can't be "half pending and half authorized." The compiler enforces this invariant. If you add a new PaymentState, every pattern match on PaymentState will fail to compile, forcing you to update your code. This is domain-driven design encoded into types.

Enums in Scala 3

Scala 3 introduces enums as a cleaner syntax for sum types. Enums are not second-class citizens like in Java—they're a convenient syntax for sealed hierarchies. An enum is semantically equivalent to a sealed trait with case objects/classes as subtypes.

// Scala 3 enum: replaces sealed trait + case objects
enum Status:
  case Pending, Processing, Shipped, Delivered, Cancelled

// Use like a sum type
case class Order(id: String, status: Status)

def orderSummary(order: Order): String = order.status match {
  case Status.Pending => s"Order ${order.id} awaiting approval"
  case Status.Processing => s"Order ${order.id} is being processed"
  case Status.Shipped => s"Order ${order.id} is in transit"
  case Status.Delivered => s"Order ${order.id} has arrived"
  case Status.Cancelled => s"Order ${order.id} was cancelled"
}

val order = Order("O1", Status.Shipped)
println(orderSummary(order))

// Enums with associated data (like case classes)
enum Tree[A]:
  case Leaf(value: A)
  case Branch(left: Tree[A], right: Tree[A])

// Useful for building data structures
val tree: Tree[Int] = Tree.Branch(
  Tree.Leaf(1),
  Tree.Branch(
    Tree.Leaf(2),
    Tree.Leaf(3)
  )
)

// Recursively process
def sumTree(tree: Tree[Int]): Int = tree match {
  case Tree.Leaf(value) => value
  case Tree.Branch(left, right) => sumTree(left) + sumTree(right)
}

println(sumTree(tree))  // 1 + 2 + 3 = 6

Enums in Scala 3 are syntactic sugar for the sealed trait pattern, but they're cleaner and more concise. They're the modern way to express algebraic data types in Scala.

Modeling Domain Data with ADTs

ADTs are perfect for expressing domain concepts precisely. By combining product and sum types, you can model complex business logic as data structures. The type system then ensures that invalid states are unrepresentable. If a payment can't be simultaneously Pending and Captured, the type system won't allow it—the PaymentState sum type forces a choice.

// A recipe engine: let's model recipes, ingredients, and cooking instructions

// Ingredient: a product type (measurement AND quantity AND name)
case class Ingredient(name: String, quantity: Double, unit: String) {
  def display: String = s"$quantity $unit $name"
}

// Cooking method: a sum type (either pan-frying OR oven-baking OR boiling)
sealed trait CookingMethod
case class PanFry(temperature: Int, duration: Int) extends CookingMethod
case class Bake(temperature: Int, duration: Int) extends CookingMethod
case class Boil(duration: Int, cover: Boolean) extends CookingMethod

// Step in a recipe: combines a sum and product type
sealed trait Step
case class PrepStep(name: String, duration: Int) extends Step
case class CookStep(method: CookingMethod, ingredients: List[Ingredient]) extends Step
case class FinishStep(garnish: String, plating: String) extends Step

// A recipe combines all of these
case class Recipe(
  name: String,
  servings: Int,
  difficulty: String,
  steps: List[Step]
) {
  def totalTime: Int = steps.map {
    case PrepStep(_, duration) => duration
    case CookStep(PanFry(_, duration), _) => duration
    case CookStep(Bake(_, duration), _) => duration
    case CookStep(Boil(duration, _), _) => duration
    case FinishStep(_, _) => 5  // Assume 5 min for finishing
  }.sum
}

// Pattern match to analyze recipes
def describeStep(step: Step): String = step match {
  case PrepStep(name, mins) =>
    s"Prep: $name ($mins minutes)"

  case CookStep(PanFry(temp, mins), ingredients) =>
    s"Pan fry at ${temp}°C for $mins minutes with ${ingredients.map(_.name).mkString(", ")}"

  case CookStep(Bake(temp, mins), ingredients) =>
    s"Bake at ${temp}°C for $mins minutes"

  case CookStep(Boil(mins, covered), ingredients) =>
    val coverStr = if (covered) "covered" else "uncovered"
    s"Boil $coverStr for $mins minutes"

  case FinishStep(garnish, plating) =>
    s"Plate and garnish with $garnish"
}

// Build a recipe
val pastaCarbo = Recipe(
  name = "Pasta Carbonara",
  servings = 4,
  difficulty = "Easy",
  steps = List(
    PrepStep("Chop guanciale", 10),
    CookStep(
      PanFry(temperature = 180, duration = 3),
      List(Ingredient("guanciale", 150.0, "g"))
    ),
    PrepStep("Cook pasta", 12),
    CookStep(
      Boil(duration = 10, cover = false),
      List(Ingredient("pasta", 400.0, "g"))
    ),
    PrepStep("Whisk eggs and cheese", 5),
    CookStep(
      PanFry(temperature = 80, duration = 2),  // Low heat to avoid curdling
      List(Ingredient("eggs", 4.0, "whole"), Ingredient("pecorino", 100.0, "g"))
    ),
    FinishStep(garnish = "black pepper and guanciale", plating = "shallow bowls")
  )
)

pastaCarbo.steps.foreach(step => println(describeStep(step)))
println(s"Total time: ${pastaCarbo.totalTime} minutes")

This example demonstrates how ADTs let you model a complex domain. A Recipe is a product type combining several fields. The steps field is a list of Step, which is a sum type. Each Step alternative has its own associated data. This structure naturally expresses the domain: recipes consist of steps, steps are either prep, cook, or finish, and cook steps require a cooking method and ingredients. The type system ensures you can't accidentally create invalid combinations—you can't have a CookStep with a Boil temperature, because Boil doesn't have a temperature field.

Complete Worked Example: A Card Game Command System

Here's a complete example modeling a card game with ADTs:

// DOMAIN TYPES

// Cards
case class Card(suit: String, rank: String) {
  def display: String = s"$rank$suit"
}

// Player
case class Player(name: String, hand: List[Card], health: Int) {
  def drawCard(card: Card): Player = copy(hand = hand :+ card)
  def playCard(card: Card): Player = copy(hand = hand.filterNot(_ == card))
  def takeDamage(amount: Int): Player = copy(health = math.max(0, health - amount))
}

// Game state
case class GameState(
  players: List[Player],
  currentPlayerIndex: Int,
  deck: List[Card],
  discard: List[Card]
) {
  def currentPlayer: Player = players(currentPlayerIndex)

  def switchTurn: GameState = {
    val nextIndex = (currentPlayerIndex + 1) % players.length
    copy(currentPlayerIndex = nextIndex)
  }
}

// COMMAND TYPES

sealed trait GameCommand
case class DrawCard(playerName: String) extends GameCommand
case class PlayCard(playerName: String, card: Card, targetPlayer: String) extends GameCommand
case class EndTurn() extends GameCommand
case class CheckGameState() extends GameCommand

// GAME LOGIC

object CardGameEngine {
  def executeCommand(state: GameState, cmd: GameCommand): (GameState, String) = cmd match {

    case DrawCard(playerName) =>
      val playerIdx = state.players.indexWhere(_.name == playerName)
      if (playerIdx == -1) {
        (state, s"Player $playerName not found")
      } else if (state.deck.isEmpty) {
        (state, "Deck is empty!")
      } else {
        val drawnCard = state.deck.head
        val updatedPlayers = state.players.updated(
          playerIdx,
          state.players(playerIdx).drawCard(drawnCard)
        )
        (state.copy(players = updatedPlayers, deck = state.deck.tail),
          s"$playerName drew ${drawnCard.display}")
      }

    case PlayCard(playerName, card, targetName) =>
      val playerIdx = state.players.indexWhere(_.name == playerName)
      val targetIdx = state.players.indexWhere(_.name == targetName)

      if (playerIdx == -1 || targetIdx == -1) {
        (state, "Player or target not found")
      } else if (!state.players(playerIdx).hand.contains(card)) {
        (state, s"$playerName doesn't have ${card.display}")
      } else {
        // Simple damage: card rank as damage (Jack=11, Queen=12, King=13, Ace=14)
        val damage = card.rank match {
          case "J" => 11
          case "Q" => 12
          case "K" => 13
          case "A" => 14
          case n => n.toIntOption.getOrElse(1)
        }

        val updatedPlayers = state.players.updated(playerIdx,
          state.players(playerIdx).playCard(card)
        ).updated(targetIdx,
          state.players(targetIdx).takeDamage(damage)
        )

        (state.copy(
          players = updatedPlayers,
          discard = state.discard :+ card
        ),
          s"$playerName played ${card.display} for $damage damage to $targetName!")
      }

    case EndTurn() =>
      val nextState = state.switchTurn
      (nextState, s"Turn passed to ${nextState.currentPlayer.name}")

    case CheckGameState() =>
      val playerStatuses = state.players.map { p =>
        s"${p.name}: ${p.health} HP, ${p.hand.length} cards"
      }.mkString(" | ")
      (state, s"Game state: $playerStatuses")
  }
}

// PLAY THE GAME!

val initialDeck = List(
  Card("♠", "A"), Card("♠", "K"), Card("♠", "Q"), Card("♠", "J"),
  Card("♥", "A"), Card("♥", "K"), Card("♥", "Q"),
  Card("♦", "A"), Card("♦", "K"),
  Card("♣", "A"), Card("♣", "K")
)

val players = List(
  Player("Alice", List(), 20),
  Player("Bob", List(), 20)
)

var gameState = GameState(
  players = players,
  currentPlayerIndex = 0,
  deck = initialDeck,
  discard = List()
)

// Simulate some gameplay
println("=== CARD GAME START ===\n")

def executeAndPrint(cmd: GameCommand): Unit = {
  val (newState, message) = CardGameEngine.executeCommand(gameState, cmd)
  gameState = newState
  println(message)
}

executeAndPrint(DrawCard("Alice"))
executeAndPrint(DrawCard("Alice"))
executeAndPrint(DrawCard("Bob"))
executeAndPrint(DrawCard("Bob"))
executeAndPrint(CheckGameState())

println()
executeAndPrint(PlayCard("Alice", Card("♠", "K"), "Bob"))
executeAndPrint(CheckGameState())

println()
executeAndPrint(EndTurn())
executeAndPrint(DrawCard("Bob"))
executeAndPrint(PlayCard("Bob", Card("♥", "A"), "Alice"))
executeAndPrint(CheckGameState())

This example shows a complete, realistic system built with ADTs. GameCommand is a sealed sum type representing all possible actions. GameState is a product type combining players, deck, and game state. The executeCommand function handles every command type exhaustively—if you add a new command, the compiler will complain until you handle it. This is domain-driven design in action: the types express the structure of the system, and the compiler enforces correctness.


Conclusion: Why ADTs Matter

Algebraic Data Types—with sealed traits, case classes, and pattern matching—are how you model domains precisely in Scala. They allow the compiler to help you:

  • Ensure exhaustiveness: sealed types force you to handle all cases
  • Prevent errors: case class equality means comparing objects works correctly
  • Express intent: the type itself documents your domain
  • Refactor safely: changing a case class affects all pattern matches, compile error

In the next part of this book, we'll explore how these patterns combine with Scala's functional programming features to build robust, maintainable systems.