Key Takeaways
- Scala case classes reduce boilerplate for data-oriented types by supplying value equality, hashing, readable output, copying, and pattern-matching support.
- Constructor fields are public vals by default, but case classes are not necessarily deeply immutable.
- The generated copy method creates a new instance and can replace selected fields; it does not clone referenced objects.
- Construction without new isn't exclusive to case classes: Scala 3 also supports it for the regular User class shown below.
Scala case classes are classes designed to make working with data values convenient. Add the case keyword, and the compiler supplies methods for comparing, copying, displaying, and deconstructing instances instead of making you write that machinery yourself. They work well for immutable data, but the keyword alone doesn't guarantee immutability.
Example versions: The complete Scala programs below use brace syntax supported by Scala 2.13 and Scala 3. Each program is independent; the construction comparison explicitly calls out the difference between those versions.
What Is a Case Class?
A case class is still a class: it can have methods and participate in a type hierarchy. Its distinguishing feature is compiler-generated support for treating its constructor fields as a data value. If you're coming from Java, our Scala vs Java introduction provides broader language context.
Case Class vs Regular Class
Start with the same data represented two ways:
object EqualityDemo {
class User(var name: String, var age: Int)
case class CaseUser(name: String, age: Int)
def main(args: Array[String]): Unit = {
val user1 = new User("Joe", 30)
val user2 = new User("Joe", 30)
val caseUser1 = CaseUser("Joe", 30)
val caseUser2 = CaseUser("Joe", 30)
println(user1 == user2) // false
println(caseUser1 == caseUser2) // true
val users = scala.collection.immutable.HashSet(
caseUser1, caseUser2
)
println(users.size) // 1
}
}Constructor Parameters: val by Default, Not Immutable by Magic
In CaseUser, name and age are public val fields by default: you can read them, but you can't reassign them. The regular User explicitly declares var fields, making them reassignable. A regular class can also declare val fields; mutability isn't what determines whether a class has value equality.
For the underlying distinction between a fixed binding and a reassignable one, see Scala val vs var.
The new Keyword in Scala 2 and Scala 3
For the class definitions above, these construction forms work:
| Version | Regular User | CaseUser |
|---|---|---|
| Scala 2.13 | new User("Joe", 30) | CaseUser("Joe", 30) |
| Scala 3 | User("Joe", 30) or new User("Joe", 30) | CaseUser("Joe", 30) |
In Scala 2.13, CaseUser(...) calls the generated companion apply method. The shown regular User needs new unless you define an appropriate factory. Scala 3's universal apply methods, also called creator applications, allow User(...) for this regular class too.
Equality and hashCode
For these reference objects, Scala's == uses equals with null handling. User inherits reference-based equality because we haven't overridden it; regular classes can implement value equality themselves. CaseUser receives generated structural equality, which compares its product elements—the fields in its first constructor parameter list.
Consequently, the two CaseUser instances compare equal despite being separate objects. Their generated hashCode implementations are consistent with that equality, so the HashSet example contains one distinct value. Equal objects must have equal hash codes; equal hash codes alone do not establish equality. The standard-library equality contract explains that distinction.
Scala Case Class Methods
For the simple concrete case classes in this tutorial, the main generated members fit into two groups: instance methods and companion methods. A companion object is an object associated with a class under the same name.
case class CaseUser(name: String, age: Int)
|
+-- Instance methods
| equals / hashCode compare and hash data values
| toString display the class name and fields
| copy construct a modified copy
|
+-- Companion methods
apply construct an instance
unapply support extraction in patternsThat diagram describes the ordinary generated behavior, not a requirement to write those methods yourself. Custom definitions and certain specialized declarations can change what the compiler generates.
copy Creates a New Instance
Use a named argument to change one field while retaining the others:
object CopyDemo {
case class CaseUser(name: String, age: Int)
def main(args: Array[String]): Unit = {
val user = CaseUser("Joe", 30)
val olderUser = user.copy(age = 31)
println(user) // CaseUser(Joe,30)
println(olderUser) // CaseUser(Joe,31)
assert(user.age == 30)
assert(olderUser.age == 31)
assert(!(user eq olderUser))
}
}The generated copy method constructs a new instance; it doesn't update user. Omitted arguments take their values from the original instance. The printed output also demonstrates the generated toString method.
Case Class Immutability Is Shallow
A val prevents reassignment of a field, not mutation of the object it references. You can also explicitly declare a case-class constructor field as var. Both exceptions matter when you share records or copy them.
This example uses an ArrayBuffer, a mutable collection, to make the distinction visible:
import scala.collection.mutable.ArrayBuffer
object MutabilityDemo {
case class MutableUser(var age: Int)
case class Team(name: String, members: ArrayBuffer[String])
def main(args: Array[String]): Unit = {
val user = MutableUser(30)
user.age = 31 // Explicit var: reassignment is allowed.
assert(user.age == 31)
val original = Team("Platform", ArrayBuffer("Joe"))
val copied = original.copy(name = "Infrastructure")
copied.members += "Maya"
assert(original.members eq copied.members)
assert(original.members.size == 2)
// Does not compile: members is a val.
// original.members = ArrayBuffer("Sam")
}
}The Team copy is shallow: both instances reference the same buffer. Prefer immutable collections containing immutable values when you want records whose reachable data cannot change. An immutable collection doesn't make mutable objects stored inside it immutable.
Why Use a Case Class?
Choose a case class when the data values define what the object means: a coordinate, a configuration record, or an event payload. Choose a regular class when identity, hidden mutable state, or a deliberately different equality policy is central to the design. That's a modeling decision, not a rule that all classes should become case classes.
Immutable records can simplify sharing because readers don't have to coordinate changes to those records. But don't infer thread safety from case alone: the mutable buffer above remains mutable after you put it inside a case class.
Pattern Matching Deconstructs Data
Pattern matching lets you recognize a value's shape and bind its fields to names:
object MatchDemo {
case class CaseUser(name: String, age: Int)
def main(args: Array[String]): Unit = {
val user = CaseUser("Joe", 30)
user match {
case CaseUser(name, age) =>
println(s"Hello, my name is $name and I am $age years old.")
}
}
}The generated companion unapply method supplies extractor support for the CaseUser(name, age) pattern. Unlike construction through apply, extraction takes an existing value apart. This single-case example illustrates extraction from a known CaseUser; it isn't a general demonstration of exhaustive matching across multiple alternatives.
Sealed Hierarchies Enable Exhaustiveness Checking
A sealed trait restricts its direct subclasses to the same source file. Combine it with final case-class variants to describe a closed set of alternatives that the compiler can check when you match.
object EventDemo {
sealed trait UserEvent
final case class Registered(name: String) extends UserEvent
final case class AgeChanged(age: Int) extends UserEvent
def describe(event: UserEvent): String = event match {
case Registered(name) => s"Registered $name"
case AgeChanged(age) => s"Age changed to $age"
}
def main(args: Array[String]): Unit = {
println(describe(Registered("Joe"))) // Registered Joe
println(describe(AgeChanged(31))) // Age changed to 31
}
}Both variants are covered. Remove one branch and the compiler can warn that the match isn't exhaustive. Avoid an unnecessary catch-all branch when you want missing alternatives to stay visible during future changes.
For optional values instead of a custom event hierarchy, see Scala Option, Some, and None.
Large Collections vs One Very Wide Record
Case classes are useful for modeling individual records inside a large collection, but they don't make processing that collection efficient by themselves. Choose the collection and algorithms for your access patterns; the official collection performance tables are a better guide to those decisions.
A single case class with many fields is a different design problem. Consider grouping related fields into meaningful nested types instead of building an unwieldy constructor. For performance-sensitive paths, measure allocation from repeated copies and comparisons of large referenced values rather than assuming generated methods are free.
Common Mistakes
- Adding case only to avoid new: that construction distinction doesn't hold for the shown regular class in Scala 3.
- Treating copy as a deep clone: referenced mutable values remain shared unless you replace them.
- Logging every field automatically: generated toString includes the class's elements, so avoid logging whole records that contain secrets.
FAQ
What methods does Scala generate for a case class?
For a simple concrete case class, the main generated methods are companion apply and unapply, plus instance copy, equals, hashCode, and toString. These provide construction, extraction, copying, value equality, hashing, and readable output.
Are Scala case classes deeply immutable?
No. Constructor fields are vals by default, but you can declare vars, and a val can reference a mutable object. Deep immutability requires the referenced data to be immutable too.
What is the difference between a case class and a regular class in Scala?
A case class supplies data-oriented behavior such as structural equality, copying, and extractor-based pattern matching. A regular class can implement those features manually; in Scala 3, construction without new is also available for ordinary classes such as the User example.
How does the copy method work in a Scala case class?
The generated copy method creates a new instance, taking omitted field values from the original and applying any supplied replacements. For example, user.copy(age = 31) changes age in the new instance, not the original; referenced objects are not cloned.
Can I use case classes for large data structures?
Yes, as records within a large collection or as nodes in a structured model. That doesn't guarantee efficient processing: choose suitable collections and algorithms, and distinguish many small records from one case class with an unwieldy number of fields.
Further Reading
- Tour of Scala: Case Classes — field defaults, equality, and shallow copying.
- Scala Book: Domain Modeling Tools — classes, case classes, and data-modeling choices.
- Scala 2.13 Specification: Classes and Objects — precise generated-member and constructor-element rules.
- Scala 3: Universal Apply Methods — construction without new and its conditions.
- Tour of Scala: Pattern Matching — extraction, sealed types, and exhaustiveness checking.
- Scala 3 Migration Guide: Other Changed Features — generated unapply differences.
- Scala Collections: Equality — equality, hashing, and mutable-key hazards.
- Mutable and Immutable Collections — collection mutability guarantees.
