100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Programming

Case Classes Explained

Understand what case class generates for free -- structural equality, copy, and pattern matching support -- and how pairing it with sealed traits builds safe, exhaustiveness-checked data models.

OOP & Functional FeaturesIntermediate10 min readJul 10, 2026
Analogies

Case Classes Explained

A case class is Scala's concise way to define a class optimized for holding immutable data: prefixing a class with case, as in case class Point(x: Int, y: Int), automatically generates a companion object with an apply factory (no new needed), structural equals/hashCode, a readable toString, an unapply extractor for pattern matching, and a copy method -- all from one line of code. Every constructor parameter of a case class is a val by default, making instances immutable unless you explicitly mark a parameter var.

🏏

Cricket analogy: Declaring case class Delivery(bowler: String, speed: Double, result: String) is like the ball-by-ball commentary system auto-generating a complete record for every delivery Jasprit Bumrah bowls -- speed, result, and a readable summary are all produced automatically without a commentator manually typing each field.

Structural Equality and toString

Because case classes generate equals and hashCode based on their field values rather than object identity, two separately constructed case class instances with the same field values are considered equal with ==, unlike ordinary classes where == defaults to reference equality (are they the same object in memory). The auto-generated toString similarly prints the class name and all field values, for example Point(3,4), which makes debugging and logging dramatically easier than the default MyClass@1a2b3c hash-based output of a plain class.

🏏

Cricket analogy: Two Point(3,4) case class instances comparing equal is like two scorecards for the same over -- even though they're written on separate pieces of paper by two scorers, if every ball's runs match, the scorecards are considered the same record, not judged by which physical paper they're on.

scala
case class Point(x: Int, y: Int)

val p1 = Point(3, 4)
val p2 = Point(3, 4)
println(p1 == p2)        // true, structural equality
println(p1)               // Point(3,4)
val p3 = p1.copy(x = 10)  // Point(10,4)

sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape

def area(s: Shape): Double = s match {
  case Circle(r)        => math.Pi * r * r
  case Rectangle(w, h)  => w * h
}

The copy Method and Immutability

Because case class fields default to val, the idiomatic way to produce a modified version of an instance is copy, which creates a new instance with all fields identical to the original except the ones you explicitly override by name, e.g., original.copy(salary = 60000). This pattern avoids mutation entirely -- the original instance is untouched -- which makes case classes especially safe to share across threads and easy to reason about, since nothing can silently change a case class value out from under you once it's created.

🏏

Cricket analogy: Using .copy(runs = 50) on a Score case class is like a scorer producing a fresh scorecard for a new innings that reuses the same team names and venue but updates just the runs -- the original first-innings card is never overwritten.

Sealed Traits with Case Classes (Algebraic Data Types)

Case classes are most powerful when combined with a sealed trait to model algebraic data types: a sealed trait Shape with case classes Circle, Rectangle, and Triangle extending it defines a closed set of possible shapes that the compiler knows about completely, because sealed forbids extending the trait from outside the file. This lets the compiler warn you if a match expression on a Shape fails to handle every case, catching an entire class of bugs at compile time that would otherwise only surface as a runtime MatchError.

🏏

Cricket analogy: A sealed trait DismissalType with cases Bowled, Caught, RunOut, LBW is like the ICC maintaining a closed, official list of how a batter can get out -- a scorer's match report using match on dismissal type gets flagged by the umpire's checklist if a new, unlisted dismissal type sneaks in.

Case classes are the natural fit for domain modeling with pattern matching -- combining sealed trait and case class hierarchies is Scala's version of algebraic data types found in Haskell or F#.

A case class cannot extend another case class in Scala -- inheritance between case classes is disallowed because it would break the auto-generated equals/hashCode contract. Extend a plain trait or abstract class instead, and make the leaves case classes.

  • case class auto-generates apply, unapply, structural equals/hashCode, toString, and copy -- all constructor parameters default to val.
  • Structural equality means two case class instances with identical field values are == regardless of whether they're the same object in memory.
  • The auto-generated toString prints a readable ClassName(field1,field2,...) instead of a hash-based default.
  • copy produces a new instance with selected fields overridden, leaving the original instance completely untouched.
  • Combining sealed trait with case class subtypes models a closed algebraic data type the compiler can exhaustiveness-check.
  • A case class cannot extend another case class, to preserve the correctness of generated equality logic.
  • Case classes pair naturally with match expressions via their auto-generated unapply extractor.

Practice what you learned

Was this page helpful?

Topics covered

#Programming#ScalaStudyNotes#CaseClassesExplained#Case#Classes#Explained#Structural#OOP#StudyNotes#SkillVeris