TL;DR
- Case classes are special classes for immutable data modeling
apply,unapply,copy,equals,hashCode, andtoStringare automatically generated- Define ADTs (Algebraic Data Types) with
sealed trait+ case classes- Show their true value when used with pattern matching
Target Audience: Developers who have learned Scala basic syntax Prerequisites: Classes and objects, basic understanding of type system
Case classes are special classes for immutable data modeling. You can define data classes without boilerplate code. The compiler automatically generates useful methods like equals, hashCode, toString, and copy, so you can define data-centric classes very concisely. They show their true value especially when used with pattern matching.
Basic Syntax#
Case classes are defined with the case class keyword. You can create instances without the new keyword, because the compiler automatically generates an apply factory method.
case class Person(name: String, age: Int)
// Can create without new keyword
val alice = Person("Alice", 30)
val bob = Person("Bob", 25)Auto-Generated Features#
When you declare a case class, the compiler automatically generates several useful methods. This eliminates the need to write boilerplate code when defining data classes.
1. apply Method (Factory)
The apply method is automatically generated, allowing instance creation without the new keyword.
// Can create without new
val person = Person("Alice", 30)
// Actually works like this
val person = Person.apply("Alice", 30)2. unapply Method (Extractor)
The unapply method is automatically generated, allowing field extraction in pattern matching.
val Person(name, age) = Person("Alice", 30)
println(name) // Alice
println(age) // 303. Field Accessors
All constructor parameters are automatically declared as val and accessible from outside. Case classes are immutable by default.
val person = Person("Alice", 30)
println(person.name) // Alice
println(person.age) // 30
// Immutable, cannot modify
// person.age = 31 // Compile error!4. copy Method
Creates a new instance with some fields of an immutable object changed. The original object doesn’t change.
val alice = Person("Alice", 30)
// New instance with only age changed
val olderAlice = alice.copy(age = 31)
println(olderAlice) // Person(Alice,31)
// Change only name
val bob = alice.copy(name = "Bob")
println(bob) // Person(Bob,30)
// Change multiple fields
val carol = alice.copy(name = "Carol", age = 25)
println(carol) // Person(Carol,25)5. equals and hashCode
Provides structural equality. Unlike regular classes, compares field values rather than references.
val person1 = Person("Alice", 30)
val person2 = Person("Alice", 30)
val person3 = Person("Bob", 25)
println(person1 == person2) // true (same content)
println(person1 == person3) // false
// Works correctly in HashSet/HashMap
val set = Set(person1, person2)
println(set.size) // 1 (duplicates removed)6. toString
Provides readable string representation. Useful for debugging and logging.
val person = Person("Alice", 30)
println(person.toString) // Person(Alice,30)
println(person) // Person(Alice,30)Key Points
apply: Create instances without newunapply: Extract fields in pattern matchingcopy: Create new instance with some fields changedequals/hashCode: Structural equality (compare field values)toString: Readable string representation
Nested Case Classes#
Case classes can have other case classes as fields. Using nested copy, you can easily update even deeply structured immutable objects.
case class Address(city: String, zipCode: String)
case class Employee(name: String, address: Address)
val emp = Employee("John", Address("Seoul", "12345"))
// Nested copy
val empInBusan = emp.copy(address = emp.address.copy(city = "Busan"))
println(empInBusan) // Employee(John,Address(Busan,12345))Key Points
- Update nested case classes by chaining
copy- Can easily modify even deeply structured immutable objects
With Pattern Matching#
Case classes show their true value when used with pattern matching. Thanks to the automatically generated unapply method, you can easily extract fields and apply guard conditions.
case class Order(id: Int, product: String, quantity: Int)
def processOrder(order: Order): String = order match {
case Order(_, _, q) if q <= 0 => "Invalid quantity"
case Order(_, _, q) if q > 100 => "Bulk order"
case Order(id, product, q) => s"Order #$id: $product x$q"
}
println(processOrder(Order(1, "Laptop", 5))) // Order #1: Laptop x5
println(processOrder(Order(2, "Mouse", 150))) // Bulk order
println(processOrder(Order(3, "Keyboard", -1))) // Invalid quantityKey Points
- Easily extract fields with case class
unapply- Additional filtering possible with guard conditions (
if)- Ignore unnecessary fields with wildcard
_
ADT (Algebraic Data Types)#
You can define ADTs (Algebraic Data Types) by combining case classes with sealed trait. ADTs are a powerful way to express domain models in functional programming. Declaring as sealed requires all subtypes to be defined in the same file, so the compiler can check pattern matching completeness.
Scala 3
In Scala 3, you can define ADTs more concisely with the enum keyword.
enum Shape:
case Circle(radius: Double)
case Rectangle(width: Double, height: Double)
case Triangle(base: Double, height: Double)
import Shape.*
def area(shape: Shape): Double = shape match
case Circle(r) => math.Pi * r * r
case Rectangle(w, h) => w * h
case Triangle(b, h) => 0.5 * b * h
println(area(Circle(5))) // 78.539...
println(area(Rectangle(3, 4))) // 12.0Scala 2
In Scala 2, implement ADTs with sealed trait and case class combinations.
sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
case class Triangle(base: Double, height: Double) extends Shape
def area(shape: Shape): Double = shape match {
case Circle(r) => math.Pi * r * r
case Rectangle(w, h) => w * h
case Triangle(b, h) => 0.5 * b * h
}Importance of sealed
sealed restricts inheritance to the same file only. This provides the following benefits:
- Exhaustive pattern matching: Compiler knows all cases
- Provides warnings: Warns about missing cases
// Warning if cases are missing
def describe(shape: Shape): String = shape match {
case Circle(r) => s"Circle with radius $r"
// Rectangle, Triangle missing - compile warning!
}Key Points
- Define ADTs with
sealed trait+ case classessealedallows inheritance only in the same file- Compiler checks pattern matching completeness and warns about missing cases
- In Scala 3, can define more concisely with
enum
Option, Either, Try#
Representative case class usage examples from Scala’s standard library. These are all ADTs defined with sealed trait and case classes, expressing operations that can fail in a type-safe way instead of null or exceptions.
💡 The examples below show conceptual structure. Actual standard library implementations are more complex with various optimizations applied.
Option
Option expresses cases where a value may or may not be present. Using Option instead of null can prevent NullPointerException.
// Conceptual structure (differs from actual implementation)
sealed trait Option[+A]
case class Some[+A](value: A) extends Option[A]
case object None extends Option[Nothing]
// Usage
def divide(a: Int, b: Int): Option[Int] =
if (b == 0) None else Some(a / b)
divide(10, 2) match {
case Some(result) => println(s"Result: $result")
case None => println("Cannot divide by zero")
}Either
Either holds one of two possible types of values. By convention, Left holds failure (error messages, etc.), and Right holds success values.
sealed trait Either[+L, +R]
case class Left[+L](value: L) extends Either[L, Nothing]
case class Right[+R](value: R) extends Either[Nothing, R]
// Usage
def parseAge(input: String): Either[String, Int] =
input.toIntOption match {
case Some(age) if age >= 0 => Right(age)
case Some(_) => Left("Age cannot be negative")
case None => Left("Not a number")
}Key Points
- Option: Expresses value present (
Some) or absent (None)- Either: One of two possible results (
Left=failure,Right=success)- Expresses failure in type-safe way instead of null or exceptions
Case Class vs Regular Class#
The table below summarizes the main differences between case classes and regular classes. Case classes are optimized for immutable data modeling, and regular classes are suitable when mutable state or complex behavior is needed.
| Feature | Case Class | Regular Class |
|---|---|---|
| Immutability | Immutable by default (val) | Optional |
| equals/hashCode | Auto structural comparison | Reference comparison (default) |
| copy method | Auto-generated | Must implement manually |
| Pattern matching | unapply auto-generated | Must implement manually |
| new keyword | Not needed | Needed |
Key Points
- Case classes are optimized for immutable data
- Automatically support structural comparison, copy, and pattern matching
- Use regular classes when mutable state is needed
Best Practices#
Recommendations for effectively using case classes.
1. Use for Immutable Data
Case classes are designed for immutable data. Using var can break the immutability assumption of equals/hashCode.
// Good: Immutable data
case class Config(host: String, port: Int)
// Avoid: When mutable state is needed
// case class Counter(var count: Int) // Anti-pattern2. Good for Small Domain Models
Case classes are ideal for defining concise domain models.
case class Money(amount: BigDecimal, currency: String)
case class OrderLine(product: String, quantity: Int, unitPrice: Money)
case class Order(id: String, lines: List[OrderLine])3. DTO (Data Transfer Object)
Case classes are also suitable for API request/response objects. JSON serialization libraries also support case classes well.
case class CreateUserRequest(name: String, email: String)
case class UserResponse(id: Long, name: String, email: String)4. Composition Over Inheritance
Case class inheritance is not recommended as it can cause issues with equals/hashCode implementation. Use composition instead.
// Avoid: Case class inheritance
// case class SpecialPerson(name: String, age: Int, badge: String)
// extends Person(name, age) // Can cause issues
// Good: Use composition
case class Person(name: String, age: Int)
case class Badge(id: String, level: String)
case class Employee(person: Person, badge: Badge)Key Points
- Use case classes for immutable data, domain models, DTOs
- Avoid using
varand maintain immutability- Use composition instead of case class inheritance
Exercises#
Practice case class and ADT concepts with these exercises.
1. Result Type Implementation
Implement a Result[T] type: Success(value) or Failure(message)
Show Answer
sealed trait Result[+T]
case class Success[+T](value: T) extends Result[T]
case class Failure(message: String) extends Result[Nothing]
def divide(a: Int, b: Int): Result[Int] =
if (b == 0) Failure("Cannot divide by zero")
else Success(a / b)
divide(10, 2) match {
case Success(v) => println(s"Result: $v")
case Failure(m) => println(s"Error: $m")
}2. Expression Tree
Define an ADT for mathematical expressions and write an evaluation function.
Show Answer
sealed trait Expr
case class Num(value: Double) extends Expr
case class Add(left: Expr, right: Expr) extends Expr
case class Mul(left: Expr, right: Expr) extends Expr
def eval(expr: Expr): Double = expr match {
case Num(v) => v
case Add(l, r) => eval(l) + eval(r)
case Mul(l, r) => eval(l) * eval(r)
}
// (1 + 2) * 3
val expr = Mul(Add(Num(1), Num(2)), Num(3))
println(eval(expr)) // 9.0Next Steps#
- Pattern Matching — Advanced match expressions
- Collections — Scala collection library