Introduction
In past posts of this Why Use Scala series, we casually used case class(es) in various code snippets without spending too much time introducing them; after all, they are simple and intuitive enough that readers instinctively understand what they did, which let us focus on the actual topic of discussion.
In today's post we will finally see what they are, and what makes them so special.
The data class you always wanted
A case class is the building block for modeling data in Scala, from a two-field record up to the compiler's own abstract syntax tree. One keyword buys you structural equality, immutability by default, a clean way to produce a modified copy, and a toString you can actually read in a log line.
How do the other three languages get there?
Python: mutable unless you opt in
The @dataclass decorator generates __init__, __repr__ and a structural __eq__, which lands close to a Scala case class. Mutability stays the default, though, and locking it down is a manual opt-in. What's worse, frozen=True only stops you reassigning a field on the object: it says nothing about whatever that field points to.
@dataclass(frozen=True)
class Order:
price: float
tickers: list
order = Order(price=12.34, tickers=["AAPL", "MSFT"])
order2 = Order(price=12.34, tickers=["AAPL", "MSFT"])
print(order) # prints a fancy "Order(price=12.34, tickers=['AAPL', 'MSFT'])"
print(order == order2) # structural equality holds, it prints "True"
order.price = 123.4 # both these raises FrozenInstanceError, as expected
order.tickers = ["GOOG"] #
order.tickers.append("GOOG") # ...but this compiles, runs, and mutates
# the list **in place**. "frozen" only blocks
# reassigning a field, not mutating whatever
# that field already points to.TypeScript: compile-time only, trivially bypassed
readonly and object spread get you a similar-looking pattern, but the immutability is purely a type-checker construct. It evaporates at runtime, and structural equality doesn't exist at all: === is a simple reference equality.
interface Order {
readonly price: number;
readonly tickers: string[];
}
const order: Order = { price: 12.34, tickers: ["AAPL", "MSFT"] };
const corrected = { ...order, price: 123.4 };
console.log(corrected); // { price: 123.4, tickers: [ 'AAPL', 'MSFT' ] }
// But you can still cast or use plain JS and, 'readonly' is gone
(order as any).price = 999.0; // compiles with a cast, runs, mutates.
// And there's no free equality at all
const a: Order = { price: 12.34, tickers: ["AAPL", "MSFT"] };
const b: Order = { price: 12.34, tickers: ["AAPL", "MSFT"] };
console.log(a === b); // false: different object references, despite identical data.Java records: immutable by default, less feature-complete
Java 16's records are immutable by default and the compiler generates equals/hashCode/toString for you. Along with Python's data classes, they get close to a Scala case class, but two major annoyances still remain: you must type new on every instantiation, and there is no equivalent of .copy(), so changing one field means spelling out every unchanged one by hand. And for collection fields, you have to remember the immutable constructors, or you end up with a shallowly immutable record, exactly as in the Python case.
record Order(float price, List<String> tickers) {}
Order order = new Order(12.34f, List.of("AAPL", "MSFT"));
Order order2 = new Order(12.34f, List.of("AAPL", "MSFT"));
System.out.println(order); // prints "Order[price=12.34, tickers=[AAPL, MSFT]]"
System.out.println(order.equals(order2)); // prints "true"
// both these fail with "error: cannot assign a value to final variable"
order.price = 123.4f;
order.tickers = List.of("AAPL", "MSFT", "GOOGL")
// this throws an exception because List.of(...) returns an immutable collection
order.tickers.add("FOOO");
// No .copy(). Changing one field means re-listing every field,
// creating temporary objects to modify immutable collections,
// which gets worse (and more error-prone) as records grow
List<String> newTickers = new ArrayList<>(order.tickers);
newTickers.add("GOOGL");
Order corrected = new Order(123.4f, newTickers);
System.out.println(order); // "Order[price=123.4, tickers=[AAPL, MSFT, GOOGL]]"
// but wait, tickers' underlying type is now an ArrayList ...
corrected.tickers.add("FOOO");
System.out.println(corrected); // "Order[price=123.4, tickers=[AAPL, MSFT, GOOGL, FOOO]]"
// OUCH, shoudln't have happenedScala: one keyword to rule them all
case class Order(price: Double, tickers: List[String])
val order = Order(12.34, List("AAPL", "MSFT"))
val order2 = Order(12.34, List("AAPL", "MSFT"))
println(order) // Order(12.34,List(AAPL, MSFT))
println(order == order2) // "true"
order.price = 999.0 // does not even compile: fields are 'val' by default
order.tickers.appended("GOOGL") // RETURNS a new list; it does NOT modify the current one,
// meaning "order" instance stays the same
println(order) // Order(12.34,List(AAPL, MSFT))
val partialCorrection = order.copy(price = 123.4);
println(partialCorrection) // prints "Order(123.4,List(AAPL, MSFT))", tickers carried over
// untouched, without re-stating it
val fullCorrection = order.copy(tickers = order.tickers.appended("GOOGL")
println(fullCorrection) // prints "Order(123.4,List(AAPL, MSFT, GOOGL))"Four things happened here in that single case class definition, without you noticing:
- every field is immutable by default, with no way around it on an existing instance. Mutability is an opt-in that the developer must spell out by declaring each field with
var; - the compiler automatically generated
equals/hashCode/toString: structural equality through ==, correct hashing if you put the value in aHashMap, and a readable representation for logs and debugging; - it also generated
copymethod, so changing one field does not mean restating all of them; - collections from Scala's default package,
Listamong them, are immutable by default, which is what keeps the shallow-copy problem from the Python and Java examples from arising here.
Where .copy() pays off
The gap widens as the case class grows: give a five-field configuration object, updating one nested setting stays a one-liner in Scala:
case class RetryPolicy(
maxAttempts: Int,
backoffMs: Long,
jitter: Boolean,
retryableStatuses: Set[Int],
timeoutMs: Long,
)
val default = RetryPolicy(3, 500, true, Set(502, 503, 504), 30000)
val patient = default.copy(maxAttempts = 10, timeoutMs = 60000)
// Every other field (backoffMs, jitter, retryableStatuses)
// carried over untouched, without re-stating a single one.The Java record equivalent lists all five fields, twice, to change two of them. That diff grows with every field the type gains, and it is exactly the kind of repetitive edit that goes wrong quietly: transpose two same-typed arguments and the compiler has nothing to complain about.
Fewer chances for agentic coding mistakes
That last point matters specifically for AI-generated code. An agent asked to change one field on an immutable Java record has to reproduce every other field's value in the correct position, and a same-typed field swap (two doubles, two Strings) compiles cleanly while silently swapping the wrong values. .copy(fieldName = newValue) makes that class of mistake structurally harder to make. The agent only has to state the field(s) actually changing, by name, and everything else is guaranteed untouched by construction rather than by careful transcription.
Another aspect that makes Scala shine for agentic coding, compared to other languages, is that agents don't need to ask the user "is this collection in the case class immutable or not?" because, as explained before, the collections imported by default are all immutable, thus there is no risk of producing shallow-immutable instances upon copy.
