Scala Case Classes
Case classes are one of Scala's most loved features. A case class is a special kind of class designed for modeling immutable data. With one line, the Scala compiler generates equality checking, a readable string representation, copying, and pattern matching support — all automatically.
Defining a Case Class
case class Point(x: Double, y: Double)
val p1 = Point(3.0, 4.0) // no 'new' keyword needed
val p2 = Point(1.0, 2.0)
println(p1) // Point(3.0,4.0)
println(p1.x) // 3.0
println(p1.y) // 4.0
Notice there is no new keyword. Case classes come with a built-in factory method called apply that creates instances without requiring new.
What the Compiler Generates Automatically
case class Person(name: String, age: Int)
│
▼
Compiler auto-generates:
┌───────────────────────────────────────────┐
│ toString → Person(Alice,30) │
│ equals → structural equality │
│ hashCode → consistent with equals │
│ copy → create modified copies │
│ apply → Person("Alice", 30) │
│ unapply → enables pattern matching │
└───────────────────────────────────────────┘
Structural Equality
Regular classes compare by identity (are they the same object in memory?). Case classes compare by value (do they hold the same data?).
case class Color(r: Int, g: Int, b: Int)
val red1 = Color(255, 0, 0)
val red2 = Color(255, 0, 0)
val blue = Color(0, 0, 255)
println(red1 == red2) // true (same values)
println(red1 == blue) // false (different values)
println(red1 eq red2) // false (different objects in memory)
Think of two identical birthday cakes. They are separate physical objects, but they look and taste the same. Case class equality checks the recipe (values), not which specific cake you picked up.
The copy Method
Case classes are immutable. To create a modified version, use copy. It creates a new instance with some fields changed and the rest kept the same:
case class User(name: String, email: String, active: Boolean)
val user1 = User("Meera", "meera@example.com", true)
val user2 = user1.copy(email = "meera.new@example.com")
val user3 = user1.copy(active = false)
println(user1) // User(Meera,meera@example.com,true)
println(user2) // User(Meera,meera.new@example.com,true)
println(user3) // User(Meera,meera@example.com,false)
The original user1 is unchanged. copy produces a fresh object with only the specified fields modified. This is how functional programs "update" immutable data — they create new versions rather than modifying existing ones.
Pattern Matching with Case Classes
Case classes work seamlessly with Scala's match expression. The compiler uses the unapply method it generated to extract fields during matching:
case class Order(product: String, quantity: Int, price: Double)
def describeOrder(order: Order): String =
order match
case Order(_, q, _) if q > 100 => "Bulk order"
case Order(p, 1, _) => s"Single unit of $p"
case Order(p, q, price) if price > 1000 => s"High-value: $q x $p"
case Order(p, q, _) => s"Regular: $q x $p"
println(describeOrder(Order("Laptop", 1, 75000))) // Single unit of Laptop
println(describeOrder(Order("Pen", 500, 10))) // Bulk order
println(describeOrder(Order("Camera", 3, 15000))) // High-value: 3 x Camera
Nested Case Classes
Case classes can contain other case classes, creating rich data models:
case class Address(street: String, city: String, pin: String)
case class Employee(name: String, age: Int, address: Address)
val emp = Employee(
"Vikram",
32,
Address("MG Road", "Bangalore", "560001")
)
println(emp.name) // Vikram
println(emp.address.city) // Bangalore
println(emp)
// Employee(Vikram,32,Address(MG Road,Bangalore,560001))
Case Class vs Regular Class
Feature Case Class Regular Class
───────────────── ────────────── ─────────────────
toString Auto-generated Prints memory addr
equals / == Value equality Reference equality
hashCode Auto-generated Object identity
copy method Yes No
Pattern matching Built-in Needs unapply
'new' keyword Optional Required
Fields immutable? Yes (by default) Depends on val/var
Typical use Immutable data Stateful objects
Case Classes in Collections
Because case classes have proper equality and hashCode, they work correctly in Sets and as Map keys:
case class Tag(name: String)
val tags = Set(Tag("scala"), Tag("functional"), Tag("scala"))
println(tags) // Set(Tag(scala), Tag(functional)) — duplicate removed
val tagDescriptions = Map(
Tag("scala") -> "A JVM language",
Tag("functional") -> "A programming paradigm"
)
println(tagDescriptions(Tag("scala"))) // A JVM language
Case Classes and Sealed Hierarchies
Combining case classes with sealed traits creates a powerful pattern for modeling data with known variants — called Algebraic Data Types (ADTs):
sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
case class Triangle(base: Double, height: Double) extends Shape
def area(shape: Shape): Double =
shape match
case Circle(r) => Math.PI * r * r
case Rectangle(w, h) => w * h
case Triangle(b, h) => 0.5 * b * h
val shapes: List[Shape] = List(Circle(5), Rectangle(4, 6), Triangle(3, 8))
shapes.foreach(s => println(f"Area: ${area(s)}%.2f"))
// Area: 78.54
// Area: 24.00
// Area: 12.00
Shape (sealed)
├── Circle (case class)
├── Rectangle (case class)
└── Triangle (case class)
The compiler knows ALL possible shapes.
If you forget Triangle in a match, it warns you.
Modifying Case Class Fields with var
By default, case class fields are val. You can use var but this is strongly discouraged. It removes the immutability benefit and can cause confusing equality behavior:
// Discouraged — mutable case class
case class MutablePoint(var x: Int, var y: Int)
val p = MutablePoint(1, 2)
p.x = 10 // works but ruins immutability guarantees
Stick to immutable case classes and use copy to create updated versions. This design makes your code predictable, testable, and safe for concurrent use.
Real-World Example: API Response Modeling
case class ApiError(code: Int, message: String)
case class Product(id: String, name: String, price: Double)
case class ApiResponse(success: Boolean, data: Option[Product], error: Option[ApiError])
val success = ApiResponse(
success = true,
data = Some(Product("P001", "Wireless Headphones", 2999.0)),
error = None
)
val failure = ApiResponse(
success = false,
data = None,
error = Some(ApiError(404, "Product not found"))
)
def handleResponse(response: ApiResponse): Unit =
response match
case ApiResponse(true, Some(p), _) =>
println(s"Got product: ${p.name} at ₹${p.price}")
case ApiResponse(false, _, Some(e)) =>
println(s"Error ${e.code}: ${e.message}")
case _ =>
println("Unexpected response format")
handleResponse(success) // Got product: Wireless Headphones at ₹2999.0
handleResponse(failure) // Error 404: Product not found
This pattern — case classes for data, sealed traits for variants, pattern matching to handle each case — appears throughout real Scala codebases at companies building APIs, data pipelines, and business logic engines.
