Technology  /  Scala

๐Ÿ”ด Scala 21 guides ยท updated 2026

The JVM's hybrid OOP/functional language โ€” immutability, pattern matching, and the type system that powers Spark and Akka, from first principles to production.

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 guide
class 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 keyword
case 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 automatically
println(p1 == p2) // true โ€” structural equality generated automatically
println(p1.hashCode == p2.hashCode) // true โ€” consistent hashCode, matching the equals contract

Adding 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 unchanged
println(order) // Order(A1,100.0,pending) โ€” the original is completely untouched

copy 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.

order.copy(status = 'shipped')

unchanged, still exists

Order(id=A1, amount=100.0, status=pending)

Order(id=A1, amount=100.0, status=shipped)

Order(id=A1, amount=100.0, status=pending)

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, immutable

While 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 class
case 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 TrafficLight
case object Red extends TrafficLight
case object Yellow extends TrafficLight
case 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 classRegular class
Primary purposeHolding immutable dataEncapsulating behavior, often with mutable internal state
equals/hashCode/toStringAuto-generated, structuralManual, or inherited default (reference equality) unless overridden
Pattern matching supportBuilt-in, via generated unapplyNot automatic โ€” requires manually writing an unapply
copy for modified instancesBuilt-inNot available unless hand-written
Typical use caseAPI request/response shapes, domain events, configurationServices, 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 objects
import 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 class
implicit val employeeOrdering: Ordering[Employee] = Ordering.by(_.salary)
val sorted = employees.sorted

sortBy 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

SymptomLikely cause
Two case class instances arenโ€™t == equal despite looking identicalA 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 dataForgetting 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 classNot allowed by design โ€” extend a trait or plain class instead
A case class with a var field breaks in a Set/MapStructural 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.