Case Classes: Data Made Simple

Introduction

Case classes are one of Scala's most beloved features. They provide a concise way to create immutable data structures with automatically generated functionality including constructors, equality methods, string representations, and pattern matching support. Think of them as "data classes on steroids" that eliminate boilerplate while providing powerful capabilities.

When you define a case class, Scala automatically generates a companion object with factory methods, copy methods for creating modified instances, and extractors for pattern matching. This makes case classes perfect for modeling data, creating value objects, and functional programming patterns.

Basic Case Class Syntax

Simple Case Classes

// Basic case class definition
case class Person(name: String, age: Int, email: String)

// Scala automatically generates:
// - A companion object with apply method
// - equals and hashCode methods
// - toString method
// - copy method
// - unapply method for pattern matching
// - All fields are public and immutable by default

val alice = Person("Alice Johnson", 30, "alice@example.com")
val bob = Person("Bob Smith", 25, "bob@example.com")

// No 'new' keyword needed - uses generated apply method
println(alice)  // Person(Alice Johnson,30,alice@example.com)

// Automatic equality based on field values
val aliceCopy = Person("Alice Johnson", 30, "alice@example.com")
println(alice == aliceCopy)  // true - structural equality

// Access fields directly
println(s"${alice.name} is ${alice.age} years old")

// Use copy method to create modified instances
val olderAlice = alice.copy(age = 31)
println(olderAlice)  // Person(Alice Johnson,31,alice@example.com)

// Pattern matching works automatically
alice match {
  case Person(name, age, _) if age >= 18 => println(s"$name is an adult")
  case Person(name, age, _) => println(s"$name is a minor")
}

Case Classes with Default Parameters

case class Product(
  id: String,
  name: String,
  price: Double,
  category: String = "General",
  inStock: Boolean = true,
  tags: List[String] = List.empty
)

// Create instances with various parameter combinations
val laptop = Product("P001", "Gaming Laptop", 1299.99, "Electronics")
val book = Product("P002", "Scala Programming", 45.99, tags = List("programming", "scala"))
val defaultProduct = Product("P003", "Unknown Item", 0.0)

println(laptop)
println(book)
println(defaultProduct)

// Copy with selective field updates
val discountedLaptop = laptop.copy(price = 999.99)
val outOfStockBook = book.copy(inStock = false)

// Pattern matching with default values
def categorizeProduct(product: Product): String = product match {
  case Product(_, _, price, _, _, _) if price > 1000 => "Premium"
  case Product(_, _, price, "Electronics", _, _) if price > 100 => "Electronics"
  case Product(_, _, _, _, false, _) => "Out of Stock"
  case Product(_, _, _, category, _, tags) if tags.contains("programming") => "Programming"
  case _ => "Standard"
}

println(categorizeProduct(laptop))        // Premium
println(categorizeProduct(book))          // Programming
println(categorizeProduct(outOfStockBook)) // Out of Stock

Nested Case Classes

case class Address(
  street: String,
  city: String,
  state: String,
  zipCode: String,
  country: String = "USA"
)

case class Contact(
  email: String,
  phone: Option[String] = None,
  linkedin: Option[String] = None
)

case class Employee(
  id: Long,
  name: String,
  address: Address,
  contact: Contact,
  department: String,
  salary: Double,
  startDate: java.time.LocalDate
)

// Create nested case class instances
val address = Address("123 Main St", "San Francisco", "CA", "94105")
val contact = Contact("john.doe@company.com", Some("555-0123"))
val employee = Employee(
  id = 1001,
  name = "John Doe",
  address = address,
  contact = contact,
  department = "Engineering",
  salary = 95000.0,
  startDate = java.time.LocalDate.of(2023, 1, 15)
)

println(employee)

// Update nested fields using copy
val newAddress = employee.address.copy(city = "New York", state = "NY", zipCode = "10001")
val relocatedEmployee = employee.copy(address = newAddress)

// Pattern matching on nested structures
def getLocationInfo(emp: Employee): String = emp match {
  case Employee(_, name, Address(_, "San Francisco", "CA", _, _), _, _, _, _) =>
    s"$name works in SF"
  case Employee(_, name, Address(_, city, state, _, _), _, _, _, _) =>
    s"$name works in $city, $state"
}

println(getLocationInfo(employee))         // John Doe works in SF
println(getLocationInfo(relocatedEmployee)) // John Doe works in New York, NY

// Deep copy with multiple nested updates
val updatedEmployee = employee.copy(
  salary = 100000.0,
  contact = employee.contact.copy(phone = Some("555-9999")),
  address = employee.address.copy(street = "456 Oak Ave")
)

println(s"Updated: ${updatedEmployee.name} - ${updatedEmployee.salary} - ${updatedEmployee.contact.phone}")

Advanced Case Class Features

Case Classes with Methods

case class Rectangle(width: Double, height: Double) {
  require(width > 0, "Width must be positive")
  require(height > 0, "Height must be positive")

  // Computed properties
  def area: Double = width * height
  def perimeter: Double = 2 * (width + height)
  def isSquare: Boolean = width == height

  // Methods that return new instances
  def scale(factor: Double): Rectangle = {
    require(factor > 0, "Scale factor must be positive")
    Rectangle(width * factor, height * factor)
  }

  def rotate: Rectangle = Rectangle(height, width)

  def expandBy(amount: Double): Rectangle = {
    Rectangle(width + amount, height + amount)
  }

  // Comparison methods
  def isLargerThan(other: Rectangle): Boolean = area > other.area
  def canContain(other: Rectangle): Boolean = width >= other.width && height >= other.height

  // String formatting
  def dimensions: String = s"${width}×${height}"
  def formatted: String = f"Rectangle(${width}%.1f × ${height}%.1f, area=${area}%.1f)"
}

val rect1 = Rectangle(10.0, 5.0)
val rect2 = Rectangle(3.0, 4.0)

println(rect1.formatted)                    // Rectangle(10.0 × 5.0, area=50.0)
println(s"Is square: ${rect1.isSquare}")    // false
println(s"Perimeter: ${rect1.perimeter}")   // 30.0

val scaledRect = rect1.scale(1.5)
val rotatedRect = rect1.rotate
val expandedRect = rect2.expandBy(1.0)

println(s"Scaled: ${scaledRect.dimensions}")     // 15.0×7.5
println(s"Rotated: ${rotatedRect.dimensions}")   // 5.0×10.0
println(s"Expanded: ${expandedRect.dimensions}")  // 4.0×5.0

println(s"rect1 larger than rect2: ${rect1.isLargerThan(rect2)}")  // true
println(s"rect1 can contain rect2: ${rect1.canContain(rect2)}")    // true

Case Classes with Validation

case class BankAccount private(
  accountNumber: String,
  balance: BigDecimal,
  accountType: String,
  isActive: Boolean = true
) {
  // Additional validation in the class body
  require(balance >= 0, "Balance cannot be negative")
  require(Set("checking", "savings", "business").contains(accountType.toLowerCase), 
         "Invalid account type")

  def deposit(amount: BigDecimal): Either[String, BankAccount] = {
    if (amount <= 0) {
      Left("Deposit amount must be positive")
    } else if (!isActive) {
      Left("Account is not active")
    } else {
      Right(copy(balance = balance + amount))
    }
  }

  def withdraw(amount: BigDecimal): Either[String, BankAccount] = {
    if (amount <= 0) {
      Left("Withdrawal amount must be positive")
    } else if (!isActive) {
      Left("Account is not active")
    } else if (amount > balance) {
      Left("Insufficient funds")
    } else {
      Right(copy(balance = balance - amount))
    }
  }

  def transfer(amount: BigDecimal, to: BankAccount): Either[String, (BankAccount, BankAccount)] = {
    for {
      fromAccount <- this.withdraw(amount)
      toAccount <- to.deposit(amount)
    } yield (fromAccount, toAccount)
  }

  def deactivate: BankAccount = copy(isActive = false)
  def activate: BankAccount = copy(isActive = true)

  def formattedBalance: String = f"$$${balance}%.2f"
  def accountInfo: String = s"$accountType account $accountNumber: ${formattedBalance}"
}

object BankAccount {
  // Factory method with validation
  def create(accountNumber: String, initialBalance: BigDecimal, accountType: String): Either[String, BankAccount] = {
    if (accountNumber.isEmpty) {
      Left("Account number cannot be empty")
    } else if (!accountNumber.matches("\\d{10,12}")) {
      Left("Account number must be 10-12 digits")
    } else if (initialBalance < 0) {
      Left("Initial balance cannot be negative")
    } else {
      try {
        Right(BankAccount(accountNumber, initialBalance, accountType.toLowerCase))
      } catch {
        case e: IllegalArgumentException => Left(e.getMessage)
      }
    }
  }

  // Convenience factory methods
  def createChecking(accountNumber: String, initialBalance: BigDecimal): Either[String, BankAccount] = {
    create(accountNumber, initialBalance, "checking")
  }

  def createSavings(accountNumber: String, initialBalance: BigDecimal): Either[String, BankAccount] = {
    if (initialBalance < 100) {
      Left("Savings account requires minimum balance of $100")
    } else {
      create(accountNumber, initialBalance, "savings")
    }
  }
}

// Usage with validation
BankAccount.createChecking("1234567890", BigDecimal("500.00")) match {
  case Right(account) =>
    println(s"Account created: ${account.accountInfo}")

    // Chain operations
    val result = for {
      afterDeposit <- account.deposit(BigDecimal("100.00"))
      afterWithdraw <- afterDeposit.withdraw(BigDecimal("50.00"))
    } yield afterWithdraw

    result match {
      case Right(finalAccount) => println(s"Final balance: ${finalAccount.formattedBalance}")
      case Left(error) => println(s"Transaction failed: $error")
    }

  case Left(error) =>
    println(s"Account creation failed: $error")
}

// Transfer example
for {
  account1 <- BankAccount.createChecking("1111111111", BigDecimal("1000.00"))
  account2 <- BankAccount.createSavings("2222222222", BigDecimal("100.00"))
} yield {
  account1.transfer(BigDecimal("200.00"), account2) match {
    case Right((fromAcc, toAcc)) =>
      println(s"Transfer successful:")
      println(s"  From: ${fromAcc.accountInfo}")
      println(s"  To: ${toAcc.accountInfo}")
    case Left(error) =>
      println(s"Transfer failed: $error")
  }
}

Pattern Matching with Case Classes

Basic Pattern Matching

case class Shape(name: String, area: Double, color: String)

def describeShape(shape: Shape): String = shape match {
  case Shape("circle", area, color) => 
    f"A $color circle with area $area%.2f"
  case Shape("square", area, color) => 
    f"A $color square with side length ${math.sqrt(area)}%.2f"
  case Shape("rectangle", area, color) if area > 100 => 
    f"A large $color rectangle with area $area%.2f"
  case Shape(name, area, color) => 
    f"A $color $name with area $area%.2f"
}

val shapes = List(
  Shape("circle", 78.54, "red"),
  Shape("square", 64.0, "blue"),
  Shape("rectangle", 150.0, "green"),
  Shape("triangle", 25.0, "yellow")
)

shapes.foreach(shape => println(describeShape(shape)))

Advanced Pattern Matching

case class Order(id: String, items: List[String], total: Double, status: String, customerType: String)

def processOrder(order: Order): String = order match {
  // Specific pattern with guards
  case Order(id, items, total, "pending", "premium") if total > 1000 =>
    s"Priority processing for premium order $id worth $$${total}"

  // Pattern matching on list contents
  case Order(id, items, _, "pending", _) if items.length > 10 =>
    s"Bulk order $id with ${items.length} items needs special handling"

  // Pattern with variable binding
  case order @ Order(_, _, total, "shipped", _) if total > 500 =>
    s"Tracking high-value shipment: ${order.id}"

  // List pattern matching
  case Order(id, item :: Nil, total, "pending", _) =>
    s"Single item order $id: $item ($$${total})"

  case Order(id, firstItem :: secondItem :: _, _, "pending", _) =>
    s"Multi-item order $id starting with $firstItem and $secondItem"

  // Default cases
  case Order(id, _, _, "cancelled", _) =>
    s"Order $id was cancelled"

  case Order(id, _, total, status, customerType) =>
    s"Order $id: $status ($$${total}, $customerType customer)"
}

val orders = List(
  Order("ORD001", List("laptop", "mouse", "keyboard"), 1299.99, "pending", "premium"),
  Order("ORD002", List("book"), 29.99, "pending", "regular"),
  Order("ORD003", List("phone", "case", "charger", "headphones"), 899.99, "shipped", "regular"),
  Order("ORD004", (1 to 15).map(i => s"item$i").toList, 250.00, "pending", "bulk"),
  Order("ORD005", List("tablet"), 399.99, "cancelled", "regular")
)

orders.foreach(order => println(processOrder(order)))

Pattern Matching with Nested Case Classes

case class Address(street: String, city: String, country: String)
case class Customer(name: String, email: String, address: Address, isPremium: Boolean)
case class OrderItem(product: String, quantity: Int, price: Double)
case class Purchase(id: String, customer: Customer, items: List[OrderItem], timestamp: java.time.LocalDateTime)

def analyzePurchase(purchase: Purchase): String = purchase match {
  // Nested pattern matching
  case Purchase(id, Customer(name, _, Address(_, "San Francisco", "USA"), true), items, _) 
    if items.map(_.quantity * _.price).sum > 1000 =>
    s"High-value SF premium customer: $name (Order: $id)"

  // Pattern matching on nested lists
  case Purchase(id, Customer(name, _, _, _), OrderItem("laptop", qty, _) :: otherItems, _) 
    if qty > 1 =>
    s"Bulk laptop purchase by $name: $qty laptops + ${otherItems.length} other items (Order: $id)"

  // Complex nested pattern with guards
  case Purchase(id, customer @ Customer(_, email, Address(_, city, country), false), items, timestamp) 
    if items.length > 5 && timestamp.isAfter(java.time.LocalDateTime.now().minusDays(1)) =>
    s"Recent large order from regular customer in $city, $country: ${customer.name} ($email) - Order: $id"

  // Pattern matching with variable extraction
  case Purchase(id, Customer(name, _, Address(_, city, _), isPremium), items, _) =>
    val total = items.map(item => item.quantity * item.price).sum
    val itemCount = items.map(_.quantity).sum
    val customerType = if (isPremium) "premium" else "regular"
    s"Order $id: $name ($customerType) from $city - $itemCount items, total: $$${total}"
}

// Create test data
val sfAddress = Address("123 Market St", "San Francisco", "USA")
val nyAddress = Address("456 Broadway", "New York", "USA")
val londonAddress = Address("789 Oxford St", "London", "UK")

val customer1 = Customer("Alice Johnson", "alice@example.com", sfAddress, true)
val customer2 = Customer("Bob Smith", "bob@example.com", nyAddress, false)
val customer3 = Customer("Carol Davis", "carol@example.com", londonAddress, false)

val purchases = List(
  Purchase("P001", customer1, List(
    OrderItem("laptop", 1, 1299.99),
    OrderItem("mouse", 1, 49.99),
    OrderItem("keyboard", 1, 129.99)
  ), java.time.LocalDateTime.now()),

  Purchase("P002", customer2, List(
    OrderItem("laptop", 3, 999.99),
    OrderItem("software", 3, 199.99)
  ), java.time.LocalDateTime.now()),

  Purchase("P003", customer3, List(
    OrderItem("book", 1, 29.99),
    OrderItem("pen", 5, 2.99),
    OrderItem("notebook", 3, 9.99),
    OrderItem("marker", 10, 1.99),
    OrderItem("folder", 5, 3.99),
    OrderItem("stapler", 2, 12.99)
  ), java.time.LocalDateTime.now())
)

purchases.foreach(purchase => println(analyzePurchase(purchase)))

Case Classes vs Regular Classes

When to Use Case Classes

// ✅ Good for case classes - immutable data
case class Point(x: Double, y: Double)
case class Color(red: Int, green: Int, blue: Int)
case class User(id: Long, name: String, email: String)

// ✅ Good for case classes - value objects
case class Money(amount: BigDecimal, currency: String)
case class Temperature(value: Double, unit: String)
case class Duration(value: Long, unit: String)

// ❌ Not ideal for case classes - mutable state
class Counter {
  private var _count: Int = 0
  def increment(): Unit = _count += 1
  def count: Int = _count
}

// ❌ Not ideal for case classes - behavior-heavy objects
class DatabaseConnection(host: String, port: Int) {
  private var isConnected: Boolean = false

  def connect(): Unit = { /* connection logic */ }
  def disconnect(): Unit = { /* disconnection logic */ }
  def execute(query: String): List[Map[String, Any]] = { /* query logic */ }
}

// ✅ Good hybrid approach - case class for data, regular class for behavior
case class ConnectionConfig(host: String, port: Int, database: String, timeout: Int)

class DatabaseManager(config: ConnectionConfig) {
  def connect(): Unit = { /* use config.host, config.port, etc. */ }
  def disconnect(): Unit = { /* disconnection logic */ }
  // etc.
}

Performance Considerations

// Case classes have slight overhead for:
// - hashCode computation (based on all fields)
// - equals comparison (compares all fields)
// - toString generation

case class Person(name: String, age: Int, email: String, bio: String)

// For high-performance scenarios, consider:
class OptimizedPerson(val name: String, val age: Int, val email: String, val bio: String) {
  // Custom equals that only compares critical fields
  override def equals(obj: Any): Boolean = obj match {
    case other: OptimizedPerson => name == other.name && email == other.email
    case _ => false
  }

  // Custom hashCode based on critical fields only
  override def hashCode(): Int = (name, email).hashCode()

  // Custom toString that doesn't include all fields
  override def toString: String = s"Person($name)"
}

// For very simple data, consider tuples
type Coordinate = (Double, Double)  // Instead of case class Point(x: Double, y: Double)
type PersonInfo = (String, Int, String)  // For temporary data structures

// Benchmark example (conceptual)
def benchmarkCreation(): Unit = {
  val iterations = 1000000

  // Case class creation
  val start1 = System.nanoTime()
  (1 to iterations).foreach(i => Person(s"name$i", i % 100, s"email$i@example.com", "bio"))
  val caseClassTime = System.nanoTime() - start1

  // Regular class creation
  val start2 = System.nanoTime()
  (1 to iterations).foreach(i => new OptimizedPerson(s"name$i", i % 100, s"email$i@example.com", "bio"))
  val regularClassTime = System.nanoTime() - start2

  println(s"Case class: ${caseClassTime / 1000000}ms")
  println(s"Regular class: ${regularClassTime / 1000000}ms")
}

Practical Examples

Building a Shopping Cart System

case class Product(id: String, name: String, price: BigDecimal, category: String)

case class CartItem(product: Product, quantity: Int) {
  require(quantity > 0, "Quantity must be positive")

  def subtotal: BigDecimal = product.price * quantity
  def updateQuantity(newQuantity: Int): CartItem = copy(quantity = newQuantity)
}

case class ShoppingCart(items: List[CartItem] = List.empty, discountCode: Option[String] = None) {

  def addItem(product: Product, quantity: Int = 1): ShoppingCart = {
    items.find(_.product.id == product.id) match {
      case Some(existingItem) =>
        val updatedItems = items.map {
          case item if item.product.id == product.id => 
            item.copy(quantity = item.quantity + quantity)
          case item => item
        }
        copy(items = updatedItems)
      case None =>
        copy(items = items :+ CartItem(product, quantity))
    }
  }

  def removeItem(productId: String): ShoppingCart = {
    copy(items = items.filterNot(_.product.id == productId))
  }

  def updateQuantity(productId: String, quantity: Int): ShoppingCart = {
    if (quantity <= 0) {
      removeItem(productId)
    } else {
      val updatedItems = items.map {
        case item if item.product.id == productId => item.updateQuantity(quantity)
        case item => item
      }
      copy(items = updatedItems)
    }
  }

  def applyDiscount(code: String): ShoppingCart = copy(discountCode = Some(code))
  def removeDiscount(): ShoppingCart = copy(discountCode = None)

  def subtotal: BigDecimal = items.map(_.subtotal).sum

  def discountAmount: BigDecimal = discountCode match {
    case Some("SAVE10") => subtotal * 0.1
    case Some("SAVE20") => subtotal * 0.2
    case Some("WELCOME") => BigDecimal("10.00") min subtotal
    case _ => BigDecimal("0.00")
  }

  def total: BigDecimal = subtotal - discountAmount
  def itemCount: Int = items.map(_.quantity).sum
  def isEmpty: Boolean = items.isEmpty
}

// Usage
val laptop = Product("P001", "Gaming Laptop", BigDecimal("1299.99"), "Electronics")
val mouse = Product("P002", "Wireless Mouse", BigDecimal("49.99"), "Electronics")
val keyboard = Product("P003", "Mechanical Keyboard", BigDecimal("129.99"), "Electronics")

val cart = ShoppingCart()
  .addItem(laptop)
  .addItem(mouse, 2)
  .addItem(keyboard)
  .applyDiscount("SAVE10")

println(s"Cart items: ${cart.itemCount}")
println(s"Subtotal: ${cart.subtotal}")
println(s"Discount: ${cart.discountAmount}")
println(s"Total: ${cart.total}")

// Pattern matching on cart state
def analyzeCart(cart: ShoppingCart): String = cart match {
  case ShoppingCart(Nil, _) => 
    "Empty cart"
  case ShoppingCart(items, Some(code)) if items.length > 5 => 
    s"Large cart with discount $code"
  case ShoppingCart(items, _) if cart.total > 1000 => 
    "High-value cart"
  case ShoppingCart(item :: Nil, _) => 
    s"Single item: ${item.product.name}"
  case ShoppingCart(items, _) => 
    s"Cart with ${items.length} different items"
}

println(analyzeCart(cart))

Summary

In this lesson, you've mastered case classes and their powerful automatic features:

Automatic Generation: Factory methods, equality, toString, and copy methods
Immutable Data: Perfect for modeling domain objects and value types
Pattern Matching: Built-in extractors for sophisticated matching logic
Composition: Nested case classes for complex data structures
Validation: Combining case classes with validation logic
Performance: Understanding when to use case classes vs regular classes

Case classes are fundamental to idiomatic Scala programming, providing a clean and powerful way to model data with minimal boilerplate.

What's Next

In the next lesson, we'll explore "Traits: The Building Blocks of Behavior." You'll learn how traits enable multiple inheritance, mixin composition, and flexible design patterns that complement the data modeling capabilities of case classes.

This will complete your foundation in Scala's core object-oriented features before we move into more advanced topics.

Ready to discover traits? Let's continue!