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)))

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 val, so ridge.name works without writing val. A parameter you declare var stays mutable (see Immutability and its limits).

Trail.apply

A factory method in the companion object with the same parameters (and the same defaults) as the constructor. It is why Trail("Ridge", ...) needs no new.

Trail.unapply

The hook that makes case Trail(n, l, a, d) work. In Scala 3 it returns the instance itself, not an Option of a tuple (see Scala 2 and Scala 3), which avoids an allocation.

copy

Makes a new instance in which any parameters you name are replaced and the others keep their current value. The defaults of copy are the current field values, not the defaults of the constructor.

equals and hashCode

Structural: two instances are equal when they have the same class and equal fields, compared with ==. The hash code is computed from the same fields, so equal instances hash alike and a case class is safe as a key of a Set or Map.

toString

The class name followed by the fields in parentheses, as in Trail(Ridge,8.5,900,Hard). There are no spaces after the commas.

Product

Every case class extends scala.Product: productArity, productElement(n), productElementName(n), productElementNames, productIterator and productPrefix. Generic code (serializers, debuggers, the Tuple.fromProductTyped method) uses them to read a case class without knowing its type.

Serializable

Every case class extends java.io.Serializable, and so do case objects.

canEqual

A helper for equals that lets a subclass opt out of equality with its parent. You rarely call it.

scala.deriving.Mirror.Product

In Scala 3 the companion object of a case class extends scala.deriving.Mirror.Product, which is the compile-time and run-time description of the class that type class derivation uses (see Type Class Derivation).

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

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

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)

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

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 var parameter (case class Counter(var hits: Int)) is allowed, and then hashCode changes when the field changes.

  • A field whose type is itself mutable (an Array, a mutable.ArrayBuffer) is shared, not copied, by copy.

  • An Array field 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)

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

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:

  1. Override toString (or equals and hashCode, always together) in the class body.

  2. Put some data in a second parameter list, which is not part of equals, hashCode, toString, unapply or Product.

  3. Validate in the constructor body with require.

  4. Hide the constructor and write a smart constructor, an apply in the companion that normalises its input.

  5. Add an overload of apply to 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)

What the output shows:

  • Booking("Refugio", 2)("window seat") == Booking("Refugio", 2)("late arrival") is true and the note is absent from toString. The second list is a way to carry data that must not take part in identity (a cached value, a timestamp, an audit field). copy then 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, because b1.copy(nights = 3) is then a function that still waits for the second list).

  • Km(-4.0) gives Km(0.0). The explicit apply replaces the generated one; the constructor is private so nobody can bypass it. In Scala 3 the generated copy of a class with a private constructor is private too, so there is no back door through copy:

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

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

Implementation restriction: case classes cannot have dependencies between parameters.

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.

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

new Trail(...) and Trail(...) both work.

Trail(...) works for any class (creator applications); new is optional everywhere.

unapply

Returns Option of a tuple: Trail.unapply(t) printed SomeRidge,8.5 on 2.13.18.

Returns the instance itself (Trail(Ridge,8.5,900,Hard) on 3.10.0); no allocation. Pattern matching treats the result as a product match.

Companion as a function

The companion extends FunctionN, with tupled and curried (Trail.tupled"Pass", 5.0 works on 2.13.18).

It does not; use Trail.apply.tupled or a lambda.

Private constructor

apply and copy stay public.

apply and copy are private too (unless -Xsource:3 on 2.13).

Derivation

No Mirror; libraries use shapeless or macros.

The companion extends Mirror.Product, and derives works.

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 final and 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 Scala copy with 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 is t.name (and, from Java, t.name()).

  • A record’s fields cannot be changed; a case class permits var parameters (discouraged).

  • A record with extra fields has to declare them as static or 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 require in 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 copy it in a loop or in a State-like function. A var in a case class corrupts hash-based collections (see above).

  • The data holds arrays or other mutable structures. The compiler-generated equals compares 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 type or 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 @unroll section); a class with a builder or a sealed trait with a private implementation is easier to evolve.

  • Only a fixed set of cases is needed. An enum (see Enums) is shorter and has values and ordinal.

  • 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 for new in all classes.

  • Trail.unapply(t) returning an Option, and explanations of pattern matching that rely on it. Scala 3 allows any product-style result, and the generated unapply of a case class returns the instance.

  • Trail.tupled and Trail.curried on the companion, which only exist in Scala 2.

  • Case classes with a private constructor that still expose apply and copy. In Scala 3 they are private too.

  • The claim that case classes cannot have dependent parameters. They can since Scala 3.7.

  • case class for everything, including enumerations of constants. Scala 3 has enum, 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).

  • @unroll compiles 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.