Case Classes
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Scala 3.10 (3.10.0, released 2026-10-12) and every example was compiled and run with Scala 3.10.0 through Scala CLI on JDK 26 (checked on 2026-10-15). Where a feature depends on a release, the page names it and says what the Scala 3.9 LTS line and the 3.3 LTS line do; those statements come from compiling the same code with 3.9.0 and 3.3.8 where the text says so, and from the release notes otherwise.
A case class is a class that the compiler fills in with the members you would otherwise write by hand to model plain
data: a factory method, a pattern-matching hook, value-based equality, a readable string form and a copy method. It is
the product half of Scala’s algebraic data types (the sum half is the enum or sealed hierarchy) and it is the type
that pattern matching was designed around. The examples use the trailhead scenario, a toolkit that plans hikes on a
network of mountain trails. For the classes that case classes are built from see
Classes & Constructors and
Objects & Companions; for the matching side see
Pattern Matching and
Extractors & Pattern Bindings; for the sum types that
combine with them see Enums and
Algebraic Data Types & GADTs.
What the compiler generates
Adding the case modifier to a class definition gives it these members. The example calls every one of them on the
Trail class of the scenario.
//> using scala 3.10.0
package trailhead
enum Difficulty:
case Easy, Moderate, Hard
case Alpine(grade: Int)
case class Trail(name: String, lengthKm: Double, ascentM: Int, difficulty: Difficulty = Difficulty.Moderate)
@main def run(): Unit =
val ridge = Trail("Ridge", 8.5, 900, Difficulty.Hard) // no `new`: generated Trail.apply
val same = Trail("Ridge", 8.5, 900, Difficulty.Hard)
println(ridge) // generated toString
println(ridge == same) // structural equals
println(ridge eq same) // different objects
println(ridge.hashCode == same.hashCode) // consistent hashCode
println(Set(ridge, same).size)
val shorter = ridge.copy(lengthKm = 6.0) // copy with one change
println(shorter)
println(Trail("Meadow", 3.0, 120)) // default argument
val Trail(n, len, _, d) = ridge // unapply in a pattern
println(s"$n $len $d")
println(ridge.productArity)
println(ridge.productElementNames.toList)
println(ridge.productIterator.toList)
println(ridge.productPrefix)
println(Trail.unapply(ridge)) // Scala 3: the instance itself
println(ridge.isInstanceOf[Serializable])
println(ridge.isInstanceOf[Product])
val rows = List(("Meadow", 3.0, 120, Difficulty.Easy), ("Pass", 5.5, 400, Difficulty.Alpine(2)))
println(rows.map(Trail.apply.tupled))
Trail(Ridge,8.5,900,Hard)
true
false
true
1
Trail(Ridge,6.0,900,Hard)
Trail(Meadow,3.0,120,Moderate)
Ridge 8.5 Hard
4
List(name, lengthKm, ascentM, difficulty)
List(Ridge, 8.5, 900, Hard)
Trail
Trail(Ridge,8.5,900,Hard)
true
true
List(Trail(Meadow,3.0,120,Easy), Trail(Pass,5.5,400,Alpine(2)))
Source: Scala 3 Book: Case classes
The members, with the reflection of the class used to confirm them (a getDeclaredMethods listing of Trail on 3.10.0
shows apply, canEqual, copy, equals, hashCode, productArity, productElement, productElementName,
productElementNames, productIterator, productPrefix and toString, plus the field accessors):
| Member | What it does |
|---|---|
Class parameters |
Each parameter of the first parameter list becomes a public |
|
A factory method in the companion object with the same parameters (and the same defaults) as the constructor. It is why
|
|
The hook that makes |
|
Makes a new instance in which any parameters you name are replaced and the others keep their current value.
The defaults of |
|
Structural: two instances are equal when they have the same class and equal fields, compared with |
|
The class name followed by the fields in parentheses, as in |
|
Every case class extends |
|
Every case class extends |
|
A helper for |
|
In Scala 3 the companion object of a case class extends |
The Scala 3 compiler also gives a case class with no explicit companion a synthetic one. If you write a companion object
yourself, the compiler adds the generated members to it rather than replacing it; the companion you wrote keeps its own
members. A reflection listing of the companion Trail$ on 3.10.0 shows apply, fromProduct, toString, unapply and
writeReplace, and its interfaces are scala.deriving.Mirror$Product and java.io.Serializable.
The generated apply also gives the class a function-like shape. Trail.apply can be passed wherever a function from
the four fields is expected, and Trail.apply.tupled applies it to a tuple, as the last line of the example shows. The
companion of a Scala 2 case class is itself a function (Trail.tupled and Trail.curried exist there); in Scala 3 the
companion is not a Function4, so the same call must go through Trail.apply.
// Scala 3.10.0: this line is rejected.
println(Trail.tupled)
value tupled is not a member of object trailhead.Trail
Source: Tour of Scala: Case classes
A case class must have at least one parameter list. For a class with no data at all use a case object, or an empty
parameter list case class Empty(). The error for the missing list is a real compiler message:
case class Marker
A case class must have at least one parameter list
Source: Tour of Scala: Case classes
A case class with an empty list, a type parameter or a vararg parameter works as expected; the vararg is stored as a
Seq.
//> using scala 3.10.0
package trailhead
case class Empty()
case class Tags(names: String*)
case class Pair[A, B](first: A, second: B)
@main def run(): Unit =
println(Empty() == Empty())
println(Empty())
println(Tags("a", "b"))
println(Pair(1, "x"))
println(Pair(1, "x").copy(second = 2.5))
true
Empty()
Tags(ArraySeq(a, b))
Pair(1,x)
Pair(1,2.5)
Source: Tour of Scala: Case classes
The last line shows the type parameter of copy being inferred again: Pair[Int, String] became Pair[Int, Double].
Case objects
A case object is a singleton object with the case-class conveniences that make sense for a value with no parameters.
It gets toString (the plain name), a hashCode that is the hash of that name, Product (with productArity 0 and
productPrefix the name) and Serializable. It has no apply, unapply or copy, because there is nothing to
construct, to destructure or to modify.
The use case is a leaf of a hierarchy that carries no data. In pattern matching a case object is matched by equality
(case Open, with no parentheses).
//> using scala 3.10.0
package trailhead
sealed trait Closure
case object Open extends Closure
case object Closed extends Closure
case class Restricted(reason: String) extends Closure
object Registry:
case object Marker
@main def run(): Unit =
println(Open)
println(Open == Open)
println(Open.productPrefix)
println(Open.productArity)
println(Open.hashCode == "Open".hashCode)
println(Open.isInstanceOf[Serializable])
println(Registry.Marker)
println(Open.getClass.getName)
def describe(c: Closure): String = c match
case Open => "passable"
case Closed => "shut"
case Restricted(why) => s"restricted: $why"
println(List(Open, Closed, Restricted("snow")).map(describe))
object Plain
println(Plain.isInstanceOf[Serializable])
Open
true
Open
0
true
true
Marker
trailhead.Open$
List(passable, shut, restricted: snow)
false
Source: Scala 3 Book: Case objects
The last line is the difference from a plain object, which is not Serializable and prints with an identity-based
toString. A case object nested in another object prints as just its name.
In Scala 3 a hierarchy of singletons and case classes like the one above is usually written as an enum, which also
provides values, ordinal and valueOf (see Enums). The sealed trait plus case
objects form remains useful when the cases need different supertypes, or when you want the cases to be separate files.
Immutability and its limits
A case class is immutable by convention, not by enforcement. Its parameters are val`s, so a field cannot be reassigned,
and `copy is the way to make a changed version (as in ridge.copy(lengthKm = 6.0) above). That makes instances safe
to share between threads and safe to use as map keys, and it is the reason == can be structural.
Three things break that promise without any compiler warning, and the first two are traps rather than design choices:
-
A
varparameter (case class Counter(var hits: Int)) is allowed, and thenhashCodechanges when the field changes. -
A field whose type is itself mutable (an
Array, amutable.ArrayBuffer) is shared, not copied, bycopy. -
An
Arrayfield is compared by reference, because==on arrays is reference equality.
//> using scala 3.10.0
package trailhead
case class Elevation(profile: Array[Int])
case class Group(name: String, members: scala.collection.mutable.ArrayBuffer[String])
@main def run(): Unit =
val a = Elevation(Array(1, 2, 3))
val b = Elevation(Array(1, 2, 3))
println(a == b) // arrays compare by reference
println(a.copy() == a) // same array object
println(a.profile.sameElements(b.profile))
val g = Group("A", scala.collection.mutable.ArrayBuffer("ann"))
val g2 = g.copy(name = "B")
g.members += "bob"
println(g2.members) // the copy sees the change
false
true
true
ArrayBuffer(ann, bob)
Source: Scala 3 Book: Case classes
The var problem is the one that loses data. The next program builds a set of ten counters, changes the field of one of
them, and the set can no longer find it (a set of one to four elements does not hash, so the example uses ten):
//> using scala 3.10.0
package trailhead
case class Counter(var hits: Int)
@main def run(): Unit =
val counters = (1 to 10).map(Counter(_)).toList
val set = counters.toSet
val c = counters.head
println(set.contains(c))
c.hits = 100
println(set.contains(c))
println(set.exists(_ eq c))
println(set.size)
true
false
true
10
Source: Scala 3 Book: Case classes
The element is still in the set (exists finds it by identity), but contains looks in the bucket for the new hash
code. The rule is to keep the parameters of a case class immutable, to use Vector, List and Seq instead of Array
and ArrayBuffer, and to use copy or a new instance for changes. Arrays are also only compared by reference in a plain
tuple, so the same advice applies to data you keep in tuples (see
Tuples & Named Tuples).
Immutability is shallow. copy copies the references, so the new instance and the old one share the objects the fields
point to. With immutable fields that sharing is exactly what makes copy cheap.
Customising generated members
The compiler generates a member only if you have not defined one. An explicit definition wins, in the class itself or in
the companion (for apply and unapply). That gives five common customisations, all shown in the next program:
-
Override
toString(orequalsandhashCode, always together) in the class body. -
Put some data in a second parameter list, which is not part of
equals,hashCode,toString,unapplyorProduct. -
Validate in the constructor body with
require. -
Hide the constructor and write a smart constructor, an
applyin the companion that normalises its input. -
Add an overload of
applyto the companion; the generated one stays.
//> using scala 3.10.0
package trailhead
// 1. override one generated member: your definition wins
case class Hut(name: String, beds: Int):
override def toString: String = s"$name ($beds beds)"
// 2. exclude a field from equals/hashCode with a second parameter list
case class Booking(hut: String, nights: Int)(val note: String)
// 3. validate in the constructor
case class Grade(value: Int):
require(value >= 1 && value <= 6, s"grade out of range: $value")
// 4. smart constructor: private constructor, own apply
case class Km private (value: Double)
object Km:
def apply(value: Double): Km = new Km(math.max(0.0, value))
// 5. own apply with a different signature, generated one stays
case class Waypoint(name: String, altitudeM: Int)
object Waypoint:
def apply(name: String): Waypoint = Waypoint(name, 0)
@main def run(): Unit =
println(Hut("Refugio", 24))
println(Hut("Refugio", 24).copy(beds = 30))
val b1 = Booking("Refugio", 2)("window seat")
val b2 = Booking("Refugio", 2)("late arrival")
println(b1 == b2)
println(b1)
println(b1.copy(nights = 3)("kept?").note)
println(b1.copy(nights = 3)("new note").note)
println(Km(-4.0))
println(Km(7.5))
println(Km(7.5) == Km(7.5))
println(Waypoint("Pico"))
println(Waypoint("Pico", 2350))
println(scala.util.Try(Grade(9)))
println(Grade(3))
Refugio (24 beds)
Refugio (30 beds)
true
Booking(Refugio,2)
kept?
new note
Km(0.0)
Km(7.5)
true
Waypoint(Pico,0)
Waypoint(Pico,2350)
Failure(java.lang.IllegalArgumentException: requirement failed: grade out of range: 9)
Grade(3)
Source: Scala 3 Book: Case classes
What the output shows:
-
Booking("Refugio", 2)("window seat") == Booking("Refugio", 2)("late arrival")istrueand thenoteis absent fromtoString. The second list is a way to carry data that must not take part in identity (a cached value, a timestamp, an audit field).copythen needs the second list in full; it has no default for it, and omitting the list is a type error (value note is not a member of String => trailhead.Booking, becauseb1.copy(nights = 3)is then a function that still waits for the second list). -
Km(-4.0)givesKm(0.0). The explicitapplyreplaces the generated one; the constructor is private so nobody can bypass it. In Scala 3 the generatedcopyof a class with a private constructor is private too, so there is no back door throughcopy:
//> using scala 3.10.0
package trailhead
case class Km private (value: Double)
object Km:
def apply(value: Double): Km = new Km(math.max(0.0, value))
@main def run(): Unit =
val k = Km(7.5)
println(k.copy(value = -1.0))
println(new Km(-3.0))
method copy cannot be accessed as a member of (k : trailhead.Km) from the top-level definitions in package trailhead.
private method copy can only be accessed from class Km in package trailhead.
constructor Km cannot be accessed as a member of trailhead.Km from the top-level definitions in package trailhead.
private constructor Km can only be accessed from class Km in package trailhead.
Source: Scala 3 Book: Case classes
Both errors were produced by Scala 3.10.0. In Scala 2 a case class with a private constructor still exposes a public
apply and copy (unless the -Xsource:3 option is used; this Scala 2 behaviour was not run here), so that code must be updated when it
moves to Scala 3 (see Migrating from Scala 2).
If you override equals you must override hashCode consistently. A common mistake is to override equals to ignore a
field and forget hashCode, which breaks every hashed collection. The second parameter list above is the better way to
ignore a field because the compiler keeps both methods in step. A requirement written with require throws
IllegalArgumentException at construction time, and the same check also runs when copy makes a new instance.
Case classes with dependent fields
Scala 3.7.0 (May 2025) allows the type of one case class parameter to depend on an earlier parameter of the same
list, a path-dependent type such as option.Value. Earlier versions rejected such a class. The release notes motivate it
with a configuration system, where each setting fixes the type of its value.
//> using scala 3.10.0
package trailhead
trait Context
trait Setting(val name: String):
type Value
def userDefined(using Context): Option[Value] = None
class StringSetting(name: String) extends Setting(name):
override type Value = String
class IntSetting(name: String) extends Setting(name):
override type Value = Int
case class ConfigEntry(option: Setting, default: option.Value):
def value(using Context): option.Value = option.userDefined.getOrElse(default)
@main def run(): Unit =
given Context = new Context {}
val entries = List[ConfigEntry](
ConfigEntry(StringSetting("trail.home"), "Refugio"),
ConfigEntry(IntSetting("max.hikers"), 32)
)
entries.foreach(e => println(s"${e.option.name} = ${e.value}"))
val e = entries(1)
val n: e.option.Value = e.value
println(n)
entries.foreach:
case ConfigEntry(opt, dflt) => println(s"${opt.name}: ${dflt.getClass.getSimpleName}")
trail.home = Refugio
max.hikers = 32
32
trail.home: String
max.hikers: Integer
The compiler checks the second argument against the type that the first one fixes. A default that does not fit is rejected with a real error:
val bad = ConfigEntry(IntSetting("min.hikers"), "four")
Found: ("four" : String)
Required: ?1.Value
where: ?1 is an unknown value of type trailhead.IntSetting
The same program was compiled on three lines:
| Scala | Result for case class ConfigEntry(option: Setting, default: option.Value) |
|---|---|
3.10.0 |
Compiles and runs, output above. |
3.9.0 (LTS) |
Compiles and runs with identical output. |
3.6.4 |
|
3.3.8 (LTS) |
The same error as 3.6.4. |
So code that has to build on the 3.3 LTS line cannot use dependent case classes; the workaround there is a type parameter on the case class or a plain class with an explicit constructor.
One limitation noted in the release notes applies to patterns. When a dependent case class is matched, the types of the
extracted components are approximated to their non-dependent upper bound, so the pattern variable does not
carry the dependent type. In the next program b is a CC#a.B and cannot be given the precise type a.B:
//> using scala 3.10.0
package trailhead
trait A:
type B <: AnyRef
case class CC(a: A, b: a.B)
def foo(cc: CC): Unit = cc match
case CC(a, b) =>
val precise: a.B = b
println(precise)
Found: (b : trailhead.CC#a.B)
Required: a².B
where: a is a value in class CC
a² is a value in method foo
Use the fields through the instance (cc.b has the type cc.a.B) when you need the precise type.
Case classes and inheritance
A case class may extend a trait or an ordinary (or abstract) class, and the usual shape is a sealed parent with case
class and case object leaves (as in the Closure example). What it may not do is extend another case class. The reason
is that no equals can be right for both: a LoopTrail(name, km, laps) that equals a Trail(name, km) would break
symmetry.
//> using scala 3.10.0
package trailhead
case class Trail(name: String, lengthKm: Double)
case class LoopTrail(name: String, lengthKm: Double, laps: Int) extends Trail(name, lengthKm)
case class LoopTrail has case ancestor class Trail, but case-to-case inheritance is prohibited.
To overcome this limitation, use extractors to pattern match on non-leaf nodes.
Source: Scala 3 Book: Case classes
Model a family with a sealed trait and case class leaves, or with an enum. If you need a pattern on a non-leaf node,
write an extractor (see Extractors & Pattern Bindings).
A case class can also be declared final (it is not needed to stop case-to-case inheritance but it prevents other
subclasses) or sealed. Scala 3 also has open classes for deliberate extension (see
Inheritance: Abstract, Sealed, Open & Final).
Combine that with pattern matching, and the compiler reports forgotten cases. For a sealed parent the match is checked
for exhaustivity, as shown on Pattern Matching.
Binary compatibility and @unroll
Adding a field to a case class changes its constructor, apply, copy and unapply, which is a binary-incompatible change for
a library even if the new field has a default. This is the main reason library authors have avoided case classes in public
APIs. The Scala 3.7.0 release notes introduce @unroll (SIP-61), as an experimental annotation that makes the compiler
generate forwarders with the old signatures. The 3.10.0 release notes list it among the three SIPs that move to preview
behind the compiler option -preview, but the 3.10.0 compiler accepted the example below with no option and no warning.
On 3.9.0 the annotation is still experimental: the same file fails with "class unroll is marked @experimental" unless it
is compiled with -experimental.
//> using scala 3.10.0
package trailhead
import scala.annotation.unroll
case class Trail(name: String, lengthKm: Double, @unroll ascentM: Int = 0)
@main def run(): Unit =
println(Trail("Ridge", 8.5))
println(classOf[Trail].getConstructors.map(_.getParameterCount).sorted.toList)
println(classOf[Trail].getDeclaredMethods.filter(_.getName == "copy").map(_.getParameterCount).sorted.toList)
Trail(Ridge,8.5,0)
List(2, 3)
List(2, 3)
There are two constructors and two copy methods, one with the old two parameters and one with the new three. On 3.9.0
with //> using option -experimental the program printed the same three lines. The release notes still call the feature
a preview, so its status can change; read the SIP and the release notes before relying on it.
Scala 2 and Scala 3
The visible differences for case classes:
| Aspect | Scala 2.13 | Scala 3 |
|---|---|---|
Creating an instance |
|
|
|
Returns |
Returns the instance itself ( |
Companion as a function |
The companion extends |
It does not; use |
Private constructor |
|
|
Derivation |
No |
The companion extends |
Dependent fields |
Not allowed in a parameter list. |
Allowed since 3.7. |
The Scala 2 results come from a //> using scala 2.13.18 program that created new Trail("Ridge", 8.5), printed the
unapply, called Trail.tupled and Trail.curried.
Java records and Kotlin data classes
A Java record (final since Java 16, JEP 395) is the nearest neighbour. It also gives constructor, accessors, equals,
hashCode and toString, and it can be destructured by a record pattern. The differences are as follows:
-
A record is implicitly
finaland cannot extend a class; a case class can extend a class or trait. Records can implement interfaces, and a Scala case class can do the same with traits. -
A record has no
copy/wither. The Scalacopywith named, defaulted arguments has no Java counterpart; Java code uses a builder or a hand-written method. -
Record components are accessed as
t.name(); a Scala case class field ist.name(and, from Java,t.name()). -
A record’s fields cannot be changed; a case class permits
varparameters (discouraged). -
A record with extra fields has to declare them as
staticor compute them; a case class can have any additional members in its body. -
A case class has default arguments and a second parameter list; a record has neither, but it can have a compact constructor for validation, comparable to
requirein the body of the case class.
A Kotlin data class is the closest relative in syntax: copy with named arguments, componentN functions for
destructuring, and generated equals, hashCode and toString. The destructuring of a data class is positional and
works in a val (a, b) = x declaration, but there are no nested constructor patterns in when. A Kotlin data class
can have var properties and defaults, and it cannot be open, abstract, sealed or inner. See
Kotlin Data Classes and Destructuring.
When not to use a case class
A case class is the default for data whose identity is its content. Use something else when:
-
The object has identity or lifecycle. A connection pool, a socket, an actor or a cache is not defined by its fields; an ordinary class gives the right (identity-based)
equals. -
Fields must change. Prefer a class with controlled mutation, or keep the case class and
copyit in a loop or in aState-like function. Avarin a case class corrupts hash-based collections (see above). -
The data holds arrays or other mutable structures. The compiler-generated
equalscompares them by reference. -
The parameters are not all part of equality. Use the second parameter list, or an ordinary class with an explicit
equals. -
The class is a one-field wrapper. An
opaque typeor a value class avoids the allocation (see Opaque Types & Value Classes). -
You publish a library API that will grow. Adding fields breaks binary compatibility (see the
@unrollsection); a class with a builder or asealedtrait with a private implementation is easier to evolve. -
Only a fixed set of cases is needed. An
enum(see Enums) is shorter and hasvaluesandordinal. -
You need inheritance among your data types. Case-to-case inheritance is forbidden; use a trait hierarchy.
Where the books are outdated
Books and posts written for Scala 2 show these forms. The current Scala 3 form is the one used on this page.
-
new Trail(...)for every instantiation. Scala 3 drops the need fornewin all classes. -
Trail.unapply(t)returning anOption, and explanations of pattern matching that rely on it. Scala 3 allows any product-style result, and the generatedunapplyof a case class returns the instance. -
Trail.tupledandTrail.curriedon the companion, which only exist in Scala 2. -
Case classes with a private constructor that still expose
applyandcopy. In Scala 3 they are private too. -
The claim that case classes cannot have dependent parameters. They can since Scala 3.7.
-
case classfor everything, including enumerations of constants. Scala 3 hasenum, and case objects are the second choice. -
Advice to use
@unroll-like manual forwarder methods for binary compatibility. SIP-61 offers an annotation instead.
Scala 3.9 LTS versus 3.10
-
The generated members, case objects,
copy, the private-constructor behaviour and case-to-case inheritance error are part of Scala 3 since 3.0 and are the same on 3.9 and 3.10. The 3.9.0 compile of the dependent example had identical output. -
Dependent case classes need 3.7 or later; the 3.3 LTS line rejects them (checked with 3.3.8).
-
@unrollcompiles with no option on 3.10.0, although the release notes call it a preview. On 3.9.0 it is experimental and needs-experimental; with it, the example printed the same output.