Scala - Case Classes with Examples
Case classes address the verbosity problem in traditional Java-style classes. A standard Scala class representing a user requires explicit implementations of equality, hash codes, and string...
Key Insights
- Case classes provide automatic implementations of equals, hashCode, toString, and copy methods, eliminating 80+ lines of boilerplate per class while ensuring immutability by default
- Pattern matching with case classes creates type-safe, compiler-verified code paths that catch errors at compile time rather than runtime, particularly valuable in domain modeling and ADTs
- The synthetic companion object and apply method enable instantiation without
newkeyword and automatic extractor patterns for destructuring in match expressions
What Case Classes Solve
Case classes address the verbosity problem in traditional Java-style classes. A standard Scala class representing a user requires explicit implementations of equality, hash codes, and string representation. Case classes generate these automatically.
// Traditional class - verbose
class User(val id: Long, val name: String, val email: String) {
override def equals(obj: Any): Boolean = obj match {
case that: User => this.id == that.id && this.name == that.name && this.email == that.email
case _ => false
}
override def hashCode(): Int = {
val prime = 31
var result = 1
result = prime * result + id.hashCode()
result = prime * result + name.hashCode()
result = prime * result + email.hashCode()
result
}
override def toString: String = s"User($id, $name, $email)"
}
// Case class - concise
case class User(id: Long, name: String, email: String)
Both produce identical functionality, but the case class eliminates 15+ lines of boilerplate.
Automatic Method Generation
Case classes generate several methods automatically. Understanding what’s generated helps you write cleaner code.
case class Product(id: String, name: String, price: BigDecimal)
val product1 = Product("P001", "Laptop", BigDecimal(999.99))
val product2 = Product("P001", "Laptop", BigDecimal(999.99))
val product3 = Product("P002", "Mouse", BigDecimal(29.99))
// Structural equality (not reference equality)
println(product1 == product2) // true
println(product1 == product3) // false
// Consistent hashCode
println(product1.hashCode == product2.hashCode) // true
// Readable toString
println(product1) // Product(P001,Laptop,999.99)
// Copy with modifications
val discountedProduct = product1.copy(price = BigDecimal(799.99))
println(discountedProduct) // Product(P001,Laptop,799.99)
The copy method enables functional updates without mutation, critical for immutable data structures.
Pattern Matching and Destructuring
Case classes integrate seamlessly with Scala’s pattern matching, enabling elegant branching logic and data extraction.
sealed trait PaymentMethod
case class CreditCard(number: String, cvv: String, expiry: String) extends PaymentMethod
case class PayPal(email: String) extends PaymentMethod
case class BankTransfer(accountNumber: String, routingNumber: String) extends PaymentMethod
case object Cash extends PaymentMethod
def processPayment(method: PaymentMethod, amount: BigDecimal): String = method match {
case CreditCard(number, _, _) =>
s"Processing $$${amount} via credit card ending in ${number.takeRight(4)}"
case PayPal(email) =>
s"Processing $$${amount} via PayPal account $email"
case BankTransfer(account, routing) =>
s"Initiating bank transfer of $$${amount} to account $account"
case Cash =>
s"Accepting cash payment of $$${amount}"
}
val payment1 = CreditCard("4532123456789012", "123", "12/25")
val payment2 = PayPal("user@example.com")
println(processPayment(payment1, BigDecimal(150.00)))
println(processPayment(payment2, BigDecimal(75.50)))
The compiler verifies match exhaustiveness. If you add a new payment method without updating the pattern match, compilation fails.
Algebraic Data Types with Sealed Traits
Combining sealed traits with case classes creates algebraic data types (ADTs) that model domain logic precisely.
sealed trait ApiResponse[+T]
case class Success[T](data: T, timestamp: Long) extends ApiResponse[T]
case class Error(code: Int, message: String, timestamp: Long) extends ApiResponse[Nothing]
case object Loading extends ApiResponse[Nothing]
case class UserProfile(id: String, username: String, email: String)
def handleResponse[T](response: ApiResponse[T]): Unit = response match {
case Success(data, ts) =>
println(s"Received data at $ts: $data")
case Error(code, msg, ts) =>
println(s"Error $code at $ts: $msg")
case Loading =>
println("Loading...")
}
val successResponse = Success(UserProfile("U123", "john_doe", "john@example.com"), System.currentTimeMillis())
val errorResponse = Error(404, "User not found", System.currentTimeMillis())
handleResponse(successResponse)
handleResponse(errorResponse)
handleResponse(Loading)
This pattern ensures type safety across your application’s state management.
Nested Case Classes and Complex Structures
Case classes compose naturally for complex domain models.
case class Address(street: String, city: String, zipCode: String)
case class ContactInfo(email: String, phone: String, address: Address)
case class Employee(id: Long, name: String, contact: ContactInfo, salary: BigDecimal)
val employee = Employee(
id = 1001,
name = "Alice Johnson",
contact = ContactInfo(
email = "alice@company.com",
phone = "555-0123",
address = Address("123 Main St", "Springfield", "12345")
),
salary = BigDecimal(85000)
)
// Deep copy with nested modification
val movedEmployee = employee.copy(
contact = employee.contact.copy(
address = employee.contact.address.copy(
city = "Shelbyville",
zipCode = "54321"
)
)
)
println(movedEmployee.contact.address.city) // Shelbyville
println(employee.contact.address.city) // Springfield (unchanged)
Nested copy calls maintain immutability throughout the object graph.
Companion Objects and Factory Methods
Case classes automatically generate companion objects with apply and unapply methods. You can extend these with custom factory methods.
case class Email(local: String, domain: String) {
override def toString: String = s"$local@$domain"
}
object Email {
def fromString(email: String): Option[Email] = {
email.split("@") match {
case Array(local, domain) if local.nonEmpty && domain.nonEmpty =>
Some(Email(local, domain))
case _ => None
}
}
def isValid(email: String): Boolean = fromString(email).isDefined
}
// Using the factory method
Email.fromString("user@example.com") match {
case Some(email) => println(s"Valid email: $email")
case None => println("Invalid email format")
}
println(Email.isValid("invalid.email")) // false
println(Email.isValid("valid@email.com")) // true
This pattern separates validation logic from the case class structure.
Practical Validation with Smart Constructors
Enforce invariants by making the primary constructor private and exposing validated factory methods.
case class Temperature private(celsius: Double) {
def fahrenheit: Double = celsius * 9/5 + 32
def kelvin: Double = celsius + 273.15
}
object Temperature {
val AbsoluteZero = -273.15
def fromCelsius(celsius: Double): Either[String, Temperature] = {
if (celsius < AbsoluteZero)
Left(s"Temperature $celsius°C is below absolute zero")
else
Right(new Temperature(celsius))
}
def fromFahrenheit(fahrenheit: Double): Either[String, Temperature] = {
fromCelsius((fahrenheit - 32) * 5/9)
}
}
Temperature.fromCelsius(25) match {
case Right(temp) => println(s"${temp.celsius}°C = ${temp.fahrenheit}°F")
case Left(error) => println(error)
}
Temperature.fromCelsius(-300) match {
case Right(temp) => println(s"Valid: $temp")
case Left(error) => println(error) // Temperature -300.0°C is below absolute zero
}
This ensures invalid states are unrepresentable in your domain model.
Performance Considerations
Case classes with many fields generate larger bytecode due to synthetic methods. For high-performance scenarios with millions of instances, consider these optimizations:
// Memory-heavy case class
case class LargeRecord(
f1: String, f2: String, f3: String, f4: String,
f5: Int, f6: Int, f7: Int, f8: Int,
f9: Double, f10: Double
)
// Optimized with value class for single-field wrappers
case class UserId(value: Long) extends AnyVal
case class UserName(value: String) extends AnyVal
case class OptimizedUser(id: UserId, name: UserName)
val user = OptimizedUser(UserId(12345L), UserName("Alice"))
Value classes eliminate object allocation overhead for single-field wrappers at runtime while maintaining type safety at compile time.
Case classes form the backbone of idiomatic Scala code. They reduce boilerplate, enforce immutability, integrate with pattern matching, and enable expressive domain modeling. Master these patterns and your Scala code becomes more maintainable and less error-prone.