Case Classes in Scala: Data Modeling Without the Boilerplate
The classes guide showed you how to manually write equals, hashCode, and toString for a Point class โ and how much code that actually takes to get right. A case class generates all of that automatically, correctly, in a single keyword added to a class declaration. This isnโt a minor convenience: itโs the standard, idiomatic way to model data in Scala, and understanding exactly what it generates (and why) is essential to reading real Scala codebases.
The Problem Case Classes Solve
// Manual class โ the boilerplate from the previous guideclass Point(val x: Double, val y: Double) { override def toString: String = s"Point($x, $y)" override def equals(other: Any): Boolean = other match { case that: Point => this.x == that.x && this.y == that.y case _ => false } override def hashCode: Int = (x, y).hashCode}// Case class โ identical behavior, one keywordcase class Point(x: Double, y: Double)
val p1 = Point(1.0, 2.0)val p2 = Point(1.0, 2.0)
println(p1) // Point(1.0,2.0) โ toString generated automaticallyprintln(p1 == p2) // true โ structural equality generated automaticallyprintln(p1.hashCode == p2.hashCode) // true โ consistent hashCode, matching the equals contractAdding case to a class declaration generates, automatically and correctly: a working toString (readable, showing every field), structural equals (two instances are equal if every field matches, not just if theyโre the same reference), a matching hashCode (so the equals/hashCode contract โ covered from the Java side elsewhere in this series โ can never accidentally drift out of sync the way hand-written versions sometimes do), and โ as covered below โ several more conveniences beyond even that.
What Else Case Classes Generate
Fields are public by default, no val needed. Every parameter in a case classโs parameter list is automatically treated as if val were written, without needing to state it โ case class Point(x: Double, y: Double) gives you p1.x and p1.y directly, no extra keyword required.
No new keyword needed. Point(1.0, 2.0) works directly, without new Point(1.0, 2.0) โ this is because a case class automatically gets a companion object (covered in its own dedicated guide) with an apply method serving as a factory function.
A copy method for creating modified copies.
case class Order(id: String, amount: Double, status: String)
val order = Order("A1", 100.0, "pending")val shipped = order.copy(status = "shipped")
println(shipped) // Order(A1,100.0,shipped) โ id and amount carried over unchangedprintln(order) // Order(A1,100.0,pending) โ the original is completely untouchedcopy is arguably the single most practically important thing case classes generate โ since case classes are meant to be immutable (all fields are val, unmodifiable after construction), copy is the idiomatic way to produce โthe same data, but with one field changedโ without hand-writing a new constructor call repeating every unchanged field. Only the fields you name in copy(...) change; everything else carries over automatically from the original instance.
Automatic pattern-match support. Case classes work directly with match expressions via generated extractor logic โ covered in depth in the pattern matching guide, but worth previewing here since itโs one of the core reasons case classes and pattern matching are almost always discussed together.
def describeOrder(order: Order): String = order match { case Order(_, amount, "pending") if amount > 500 => "Large pending order" case Order(_, _, "pending") => "Pending order" case Order(_, _, "shipped") => "Already shipped" case _ => "Unknown status"}The Order(_, amount, "pending") pattern above destructures the case class directly inside the match โ pulling out amount while ignoring id (via _) and requiring status to literally equal "pending" โ all without calling any getter methods explicitly. This destructuring capability comes specifically from an auto-generated unapply method, the mechanism the companion objects guide covers in full.
Case Classes Are Meant to Be Immutable
case class Point(x: Double, y: Double)
val p = Point(1.0, 2.0)p.x = 5.0 // Error: reassignment to val โ case class fields are val by default, immutableWhile itโs technically possible to declare a case class field with var instead of the default val, doing so is strongly discouraged and genuinely rare in idiomatic code โ it undermines the entire reason case classes generate structural equality and safe sharing semantics in the first place. If a field needs to change, copy producing a new instance is the idiomatic path, not mutating the original in place.
// Nested copy โ updating a field inside a nested case classcase class Address(city: String, zip: String)case class Customer(name: String, address: Address)
val customer = Customer("Alice", Address("Boston", "02101"))val moved = customer.copy(address = customer.address.copy(city = "Cambridge"))
println(moved) // Customer(Alice,Address(Cambridge,02101))Nested immutable updates require this โcopy of a copyโ pattern โ updating a deeply nested field means calling .copy at each level from the outside in. This is a real, known ergonomic cost of immutability with plain case classes; libraries like Monocle (implementing โlensesโ) exist specifically to make deeply nested updates like this less verbose, though covering lenses in depth is beyond this guideโs scope.
Case Objects โ For Singleton Values With No Fields
sealed trait TrafficLightcase object Red extends TrafficLightcase object Yellow extends TrafficLightcase object Green extends TrafficLight
def next(light: TrafficLight): TrafficLight = light match { case Red => Green case Green => Yellow case Yellow => Red}A case object is the singleton equivalent of a case class โ used when a variant genuinely has no associated data at all (unlike, say, CreditCard(number: String), which needs a field). It still gets a sensible toString ("Red" rather than a default object identity string) and works identically inside pattern matches. The combination of sealed trait with case class/case object subtypes (previewed briefly in the traits guide) is one of the most idiomatic patterns in the entire language for modeling a closed set of variants โ some carrying data, some not.
Case Classes vs Regular Classes โ When to Choose Which
| Case class | Regular class | |
|---|---|---|
| Primary purpose | Holding immutable data | Encapsulating behavior, often with mutable internal state |
equals/hashCode/toString | Auto-generated, structural | Manual, or inherited default (reference equality) unless overridden |
| Pattern matching support | Built-in, via generated unapply | Not automatic โ requires manually writing an unapply |
copy for modified instances | Built-in | Not available unless hand-written |
| Typical use case | API request/response shapes, domain events, configuration | Services, repositories, anything managing internal mutable state or complex lifecycle logic |
The practical rule: if a typeโs job is representing data โ an Order, a UserProfile, an event in an event-sourced system โ reach for case class by default. If a typeโs job is doing something โ a DatabaseConnection, a Logger, something with meaningful internal state and behavior beyond just holding values โ a regular class is usually the better fit, since the auto-generated structural equality and copy method donโt add much value (and can even be actively confusing) for something that isnโt primarily a data holder.
A Realistic Domain Model Using Case Classes
case class Money(amount: BigDecimal, currency: String) { def +(other: Money): Money = { require(currency == other.currency, "Cannot add different currencies") Money(amount + other.amount, currency) }}
case class LineItem(product: String, quantity: Int, unitPrice: Money)
case class Order(id: String, items: List[LineItem], status: String) { def total: Money = items .map(item => Money(item.unitPrice.amount * item.quantity, item.unitPrice.currency)) .reduce(_ + _)}
val order = Order( id = "ORD-1", items = List( LineItem("Widget", 2, Money(9.99, "USD")), LineItem("Gadget", 1, Money(19.99, "USD")) ), status = "pending")
println(order.total) // Money(39.97,USD)Note that Money defines a custom + method alongside its case-class-generated members โ nothing prevents a case class from having ordinary methods in its body too; case only affects whatโs auto-generated, it doesnโt restrict what else you can add. This kind of small, focused, immutable domain model โ case classes composed together, with a few domain-specific methods layered on top โ is the standard shape of well-modeled Scala business logic.
Case Classes and JSON โ Why the Fit Is So Natural
// Using a JSON library (e.g. circe) โ case classes map almost directly onto JSON objectsimport io.circe.generic.auto._import io.circe.syntax._
case class UserProfile(id: String, name: String, active: Boolean)
val user = UserProfile("u1", "Alice", true)val json = user.asJson.noSpaces// {"id":"u1","name":"Alice","active":true}Case classesโ fields map directly onto JSON object keys, and libraries like circe or play-json use Scalaโs compile-time reflection (or macros) to generate encoders/decoders automatically from a case class definition โ no manual mapping code needed for the common case. This is a direct, practical consequence of case classes being pure data holders with a fixed, known set of fields: thereโs no hidden internal state or behavior a JSON library would need to guess how to handle, unlike an arbitrary class with private mutable fields and custom logic.
Sorting Case Classes with Ordering
case class Employee(name: String, salary: Double)
val employees = List( Employee("Bob", 65000), Employee("Alice", 72000), Employee("Carol", 58000))
val bySalaryDesc = employees.sortBy(_.salary)(Ordering[Double].reverse)val byName = employees.sortBy(_.name)
// Or define a custom Ordering directly on the case classimplicit val employeeOrdering: Ordering[Employee] = Ordering.by(_.salary)val sorted = employees.sortedsortBy combined with an Ordering (Scalaโs standard-library typeclass for comparison logic, previewed here ahead of the dedicated type classes guide) is the idiomatic way to sort a list of case classes by one or more fields, without writing a manual comparator function the way older Java code required.
Common Pitfalls
| Symptom | Likely cause |
|---|---|
Two case class instances arenโt == equal despite looking identical | A field holds a value with reference-based equality (like a plain Array) rather than a properly-equatable collection type (List, Vector) โ arrays donโt get structural equality even inside a case class |
copy silently reintroduces stale data | Forgetting to also update a nested case classโs field โ remember copy is shallow; nested fields need their own .copy call |
| Compiler error extending a case class with another case class | Not allowed by design โ extend a trait or plain class instead |
A case class with a var field breaks in a Set/Map | Structural hashCode changes if a mutable field changes after insertion, silently corrupting hash-based collections that cached the old hash |
Frequently Asked Questions
Can a case class extend another case class? No โ a case class cannot be extended by another case class (this restriction exists because combining the auto-generated equals/copy logic across an inheritance chain correctly is genuinely difficult to get right, and Scalaโs designers chose to disallow it rather than risk subtly broken equality semantics). A case class extending a trait or a plain abstract class is fine and common.
Why canโt I just always use var in a case class if I need to change a value? You technically can, but it defeats the immutability guarantees that make case classesโ generated equality and safe-sharing behavior trustworthy โ a var field could change after two instances were compared as == equal, silently invalidating that earlier comparison. copy is the idiomatic alternative that keeps every instanceโs fields fixed for its entire lifetime.
Is there a performance cost to all the auto-generated methods? Negligible โ the generated equals/hashCode/toString are ordinary compiled methods, no different in cost from hand-written equivalents. The copy method does allocate a new instance (immutability inherently means โnew objectโ rather than โmutate in placeโ), which is a real, if usually small, allocation cost worth being aware of in extremely hot code paths creating many copies per second.
Do I need sealed on every trait that has case class subtypes? Not strictly required, but strongly recommended whenever the set of subtypes is meant to be closed and exhaustively pattern-matched โ without sealed, the compiler canโt verify a match handles every possible case, since new subtypes could be defined anywhere, in any file.
Whatโs the difference between a case class and a Java record? They serve the same conceptual role (structural equality, auto-generated boilerplate for immutable data), and Java records were added specifically inspired by exactly this pattern from Scala (and Kotlinโs data classes). Case classes predate Java records by well over a decade and offer additional integration specific to Scala โ pattern matching via unapply, and seamless composition with sealed trait hierarchies.
Whatโs Next
Case classes are the backbone of idiomatic Scala data modeling โ immutable, structurally equal, and pattern-match-ready by default. Next: Companion and Singleton Objects in Scala, which explains the object keyword powering the apply/unapply methods case classes generate automatically, and how to write your own factory methods and singletons.