Scala Case Classes
Learn to create classes and case classes in Scala, comparing them with Python classes. This lesson provides hands-on exercises, troubleshooting, and guidance on when to use each.
Focus: create classes and case classes
If you've ever written a Python class and wished the compiler had your back when you typo'd an attribute name, or you've spent hours writing __repr__, __eq__, and __hash__ boilerplate, Scala's case classes are about to become your new best friend. In this lesson, you'll learn how to create both regular classes and the much more powerful case classes, and you'll see exactly how they differ from Python classes — so you can write idiomatic, robust Scala code from day one.
The problem this lesson solves
In Python, classes are flexible and dynamic. You can add attributes on the fly, and you often write a lot of boilerplate for common operations like printing, comparing, and hashing. For example, to make a simple Point class that you can print and compare, you'd write something like:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point({self.x}, {self.y})"
def __eq__(self, other):
if not isinstance(other, Point):
return False
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y))
That's a lot of code for a simple data holder, and it's easy to make mistakes—like forgetting to implement __hash__ when you override __eq__. In Scala, you face a similar problem: you want to define a lightweight data structure, but a regular class requires you to manually define constructors, getters, equality, and more. Case classes eliminate that boilerplate and give you powerful pattern matching out of the box.
Core concept / mental model
Think of a regular Scala class as a Java-style class — you control everything: constructor, methods, fields, and behavior. A case class is a data carrier — it's designed to hold immutable data and provide structural equality, hashing, and a readable string representation automatically.
If you're coming from Python, here's a quick mapping:
- Regular Scala class ≈ a Python class with explicit
__init__, methods, and custom behavior. - Case class ≈ a Python
@dataclass(frozen=True)— plus pattern matching superpowers.
In Scala, a case class automatically gives you:
- A constructor with named parameters
- Getters (accessors) for each parameter
equalsandhashCodebased on the constructor parameters- A
toStringthat prints a readable representation - A
copymethod to create modified copies - Support for pattern matching (you'll see this later)
Why immutability matters
Case class parameters are val (immutable) by default. This aligns with functional programming principles: you share data without worrying about side effects. In Python, you're used to mutable objects, but in Scala, prefer immutability unless you have a strong reason not to.
How it works step by step
Let's break down creating both a regular class and a case class.
Step 1: Create a regular class
A regular class has a primary constructor that's part of the class signature. You can define parameters with val or var to make them fields. Here's a simple regular class:
class Person(val name: String, var age: Int) {
def greet(): String = s"Hi, I'm $name and I'm $age years old."
}
val namemakesnamea read-only field (getter only)var agemakesagemutable (getter and setter)- Methods are defined inside the class body
Step 2: Create a case class
A case class is declared with case class. Let's create a simple one:
case class Point(x: Int, y: Int)
That's it! You get all the automatic features. You can instantiate it normally:
val p = Point(1, 2)
println(p) // Point(1,2)
println(p.x) // 1
Step 3: Use the copy method
One of the handiest methods is copy. It lets you create a new instance with some fields changed:
val p1 = Point(1, 2)
val p2 = p1.copy(y = 5)
println(p2) // Point(1,5)
Step 4: Pattern matching with case classes
Case classes work beautifully with pattern matching. Here's an example:
def describe(point: Point): String = point match {
case Point(0, 0) => "origin"
case Point(x, 0) => s"on x-axis at $x"
case Point(0, y) => s"on y-axis at $y"
case Point(x, y) => s"point at ($x, $y)"
}
This is a huge productivity boost compared to Python, where you'd need manual isinstance checks or if conditions.
Hands-on walkthrough
Let's put this into practice. We'll create a small domain model using both regular and case classes.
Example 1: Basic class and case class in a Scala script
Create a file person.scala and run it with scala-cli or scala (if you have Scala 2/3 installed).
// Regular class
class Person(val name: String, var age: Int) {
def birthday(): Unit = age += 1
override def toString: String = s"Person(name=$name, age=$age)"
}
// Case class
case class Address(street: String, city: String, zip: String)
// Use them
val alice = new Person("Alice", 30)
alice.birthday()
println(alice) // Person(name=Alice, age=31)
val home = Address("123 Main St", "Springfield", "12345")
println(home) // Address(123 Main St,Springfield,12345)
// Case class equality
val home2 = Address("123 Main St", "Springfield", "12345")
println(home == home2) // true
Expected output:
Person(name=Alice, age=31)
Address(123 Main St,Springfield,12345)
true
Example 2: Pattern matching a case class hierarchy
Let's model shapes as a sealed trait with case classes for each shape.
sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
case class Square(side: Double) extends Shape
def area(shape: Shape): Double = shape match {
case Circle(r) => math.Pi * r * r
case Rectangle(w, h) => w * h
case Square(s) => s * s
}
val shapes: List[Shape] = List(Circle(2), Rectangle(3, 4), Square(5))
shapes.foreach(s => println(s"${s.getClass.getSimpleName} area: ${area(s)}"))
Expected output:
Circle area: 12.566370614359172
Rectangle area: 12.0
Square area: 25.0
Example 3: Comparing with Python dataclass
For context, let's see the Python equivalent:
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(1, 2)
print(p) # Point(x=1, y=2)
print(p.x) # 1
In Scala, the case class version is more concise and gives you pattern matching for free.
Compare options / when to choose what
Both regular classes and case classes have their place. Here's a comparison to help you decide:
| Feature | Regular class | Case class |
|---|---|---|
| Primary use | Behavior-heavy objects with methods and mutable state | Immutable data carriers |
| Boilerplate | You write everything manually | Automatically generated |
| Equality | Reference equality unless overridden | Structural equality |
| Pattern matching | Manual extraction | Built-in unapply |
| Copy method | Not provided | copy method |
| Immutability | Not enforced (you can use var) |
Parameters are val by default |
| Inheritance | Can be extended freely | Prefer sealed hierarchy for exhaustive matching |
When to use which
- Use a case class when you need a simple data holder with automatic equality, hashing, and printing — e.g., DTOs, messages, configuration entries.
- Use a regular class when you need complex behavior, incremental state changes, or custom methods that aren't just accessors.
Troubleshooting & edge cases
1. Case class parameters are val — you can't reassign them
If you try p.x = 5 where p is a case class instance, you'll get a compilation error. Use copy instead.
Wrong: p.x = 5
Correct: val p2 = p.copy(x = 5)
2. Case class equality with mutable fields
If you define a regular class with var fields and override equals/hashCode based on them, changing a field can break hash-based collections. Case classes avoid this by enforcing immutability.
3. Case classes and inheritance
Case classes can extend a trait or class, but they cannot extend another case class. If you need a hierarchy, use a sealed trait and case classes extending it.
4. copy method and default parameters
If you use default parameter values in a case class, the copy method preserves them. Be careful when copying with partial arguments — it might surprise you.
Example:
case class Point(x: Int = 0, y: Int = 0)
val p = Point(1, 2)
val p2 = p.copy() // uses current values, not defaults
println(p2) // Point(1,2)
5. Null and case classes
You can pass null to a case class parameter, but that's an anti-pattern. Prefer Option for optional fields.
What you learned & what's next
In this lesson, you learned how to create classes and case classes in Scala, and you saw how they compare to Python classes. You now understand:
- The difference between a regular class and a case class
- How to create both and use automatic features like
copy, equality, and pattern matching - When to choose a case class over a regular class
- Common pitfalls like immutability and inheritance restrictions
You also completed a hands-on exercise that modeled a simple domain with both types.
Next, you'll dive into pattern matching — the perfect companion to case classes. You'll learn how to destructure case classes, use guards, and make your code more expressive with match expressions.
Practice recap
Try creating your own case class hierarchy: a sealed trait Animal with case class Dog(name: String, age: Int) and case class Cat(name: String, lives: Int). Write a function that returns a friendly description for each using pattern matching. Then, use copy to update the age of a Dog and see how immutability plays out in practice.
Common mistakes
- Trying to reassign a case class field: forgetting that case class parameters are
valand getting a compilation error; usecopyinstead. - Using a regular class for simple data carriers: you end up writing boilerplate for
equals,hashCode, andtoStringwhen a case class would do it for free. - Attempting to make one case class extend another — case classes cannot inherit from other case classes; use a
sealed traitorabstract classinstead. - Forgetting that case classes provide structural equality, so two instances with the same parameters are equal — if you need identity semantics, regular classes might be more appropriate.
- Overusing case classes for highly mutable, behavior-centric objects — they're designed for immutable data; using
varinside a case class is a code smell.
Variations
- Use
@data classin Scala 3? Actually, Scala 3 still supportscase classas the primary syntax, but you can also useenumfor sealed hierarchies. - For Java interoperability, consider regular classes when you need JavaBean-style getters and setters, or use
@BeanPropertyannotation on case class fields. - If you need custom equality or hashing, override
equalsandhashCodein a regular class instead of using a case class.
Real-world use cases
- Modeling domain events in a Scala backend service: immutable DTOs for API requests and responses, made simple with case classes and automatic JSON serialization.
- Representing AST nodes in a DSL or compiler: case classes with pattern matching to evaluate or transform expressions safely and concisely.
- Defining configuration entries in a Scala application: case classes with
copyto derive new settings while preserving immutability.
Key takeaways
- Case classes give you automatic
equals,hashCode,toString, andcopy— saving you boilerplate when creating data-holder classes. - Case class parameters are implicitly
val, enforcing immutability — usecopyto create modified instances. - Regular classes are for behavior-heavy objects with custom methods and mutable state.
- Case classes integrate seamlessly with pattern matching, enabling expressive and safe code.
- Choose case classes for DTOs, messages, and domain events; reserve regular classes for complex algorithms and services.
- Avoid extending case classes; use sealed traits to build type hierarchies that support exhaustive pattern matching.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.