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.