case is one modifier that makes the compiler synthesise about a dozen members plus a companion object. What it generates is ordinary code with ordinary consequences: an equals that walks every field, a hashCode recomputed on every call, a toString that prints whatever the constructor was handed, and a set of public signatures that become part of your binary interface the moment anyone compiles against them. This article works through each generated piece, how unapply turns a class into a pattern, the failure modes that follow from structural equality, what the construct costs, and where the modifier is the wrong tool. Algebraic data type design, variance and the type lattice belong to the type system article; deriving type class instances from a case class's shape belongs to shapeless and Scala 3 given derivation.

Advertisement

What case synthesises, and where each piece lands

Given case class Point(x: Int, y: Int), the compiler emits members in two places. Into the class itself go public val accessors for every constructor parameter (you do not write val; case implies it), equals, hashCode, toString, canEqual, copy, and one synthetic default-argument method per parameter named copy$default$1, copy$default$2 and so on. The class also gains the scala.Product members - productArity, productElement(i), productPrefix, and in Scala 3 productElementName(i) - and is marked Serializable.

Into the companion object go apply and unapply. If no companion exists the compiler creates one; if you wrote one, the synthetic members are added to yours. That single fact is the source of most companion-related surprises, covered below.

Two things case does not do are worth stating because they are widely assumed. It does not make the class final, so a plain class can still extend it. And it does not enforce immutability: case class Counter(var n: Int) compiles cleanly, generates a setter, and generates a hashCode that changes underneath you. Immutability is a convention the default (val) parameters make easy, not an invariant the modifier guarantees.

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

// what you can now write without declaring any of it
val p  = Point(1, 2)          // companion apply, no `new`
val q  = p.copy(y = 9)        // Point(1,9), p unchanged
p == Point(1, 2)              // true, field by field
p.toString                    // "Point(1,2)"
p.productArity                // 2
val Point(a, b) = p           // pattern definition via unapply

apply and unapply - the pair the construct is built on

apply is a plain factory method, which is why Point(1, 2) works without new. Being a method rather than a constructor matters: it participates in overload resolution, it accepts named and default arguments, and it can be shadowed by one you write yourself.

unapply is the half that makes the class a pattern. In Scala 2 the generated signature is def unapply(p: Point): Option[(Int, Int)], so a successful match allocates an Option and a Tuple2, and the compiler then reads _1 and _2 off the tuple to bind your variables. Scala 3 changed the shape to name-based extraction: the synthesised unapply hands back the scrutinee itself, and the compiler accepts any result that exposes _1, _2, ... at the right arity, or an isEmpty/get pair for a fallible one. The visible behaviour is identical; the allocation per match is gone.

A nested pattern compiles to nested extractor calls. case Order(id, Item(sku, _) :: _) is a type test on the scrutinee, a call to Order.unapply, accessor reads, a test that the second component is a cons cell, a call to Item.unapply, and two more reads. There is no magic layer: everything a pattern does is a method the compiler could have made you write.

equals and hashCode, and the two ways they go wrong

The generated equals checks reference identity, then that the other side canEqual this one, then compares each field of the first parameter list with ==. The generated hashCode runs a MurmurHash over the same fields, freshly, on every call - there is no cached hash field. Both are therefore O(number of fields), and equals short-circuits on the first mismatch, so comparing two wide records that differ only in the last field walks all of them.

A mutable field breaks hash-map membership

This is the failure people hit and cannot explain. Put a case class Counter(var n: Int) into a HashSet or use it as a HashMap key, then mutate n. The map placed the entry in a bucket chosen from the old hash; lookups now compute the new hash, land in a different bucket, and report that the object is absent - while iteration still yields it. The entry is live and unreachable at the same time. The same mechanism silently corrupts distinct, groupBy, and set difference. Nothing warns you, because a var in a case class is legal.

Arrays compare by reference, and only arrays

case class Row(cells: Array[String]) looks like every other field-holding case class and behaves unlike all of them. Array is the JVM array, and it inherits equals and hashCode from Object, so two Rows built from arrays with identical contents are unequal and hash differently. The surprise is calibration: every other standard collection compares structurally, so the generated equals "works" for List, Vector, Map and Set fields and quietly does not for one type. Use Vector or ArraySeq, or hand-write equals/hashCode - at which point you have given up the reason you reached for case.

Two smaller edges. A Double field holding NaN makes an instance unequal to itself, because the comparison compiles to a primitive comparison and NaN is not equal to anything. And because only the first parameter list feeds equals, hashCode, toString and unapply, a class like case class Job(id: String)(val retries: Int) compares as if retries did not exist.

copy, and why named parameters are the update idiom

copy is generated as def copy(x: Int = this.x, y: Int = this.y): Point, with each default emitted as its own synthetic method. So p.copy(y = 9) compiles to p.copy(p.copy$default$1(), 9): one accessor call per untouched field and one allocation. Named arguments are not stylistic here - they are the only way to say "this field, leave the rest", and positional copy arguments on a class with several same-typed fields are how field-swap bugs get in.

The copy is shallow. References are shared with the original, which is exactly right for immutable fields and exactly wrong for that mutable array. It is also flat, so a nested update is written by hand at every level:

// the shape that motivates optics libraries
val fixed = order.copy(
  customer = order.customer.copy(
    address = order.customer.address.copy(zip = "94107")))

// with a lens/optics library, one focused update
val fixed2 = order.focus(_.customer.address.zip).replace("94107")

Two behaviours to know. If the class already declares a member named copy, the compiler declines to generate one rather than reporting a conflict, so a hand-written copy silently takes over. And a secondary implicit parameter list is not copied - it is re-resolved at the call site - so case class Task(id: String)(implicit ec: ExecutionContext) can come out of copy attached to a different executor than the original.

The companion object, and what your own does to it

Writing object Point yourself does not suppress the synthesis; the compiler adds apply and unapply to the object you wrote. Three consequences follow. Declaring your own apply with the same signature is a duplicate definition and fails to compile - a different signature is fine and simply overloads. Declaring your own unapply does suppress the generated one, so every pattern match against the type now runs your logic, which is a powerful hook and an easy way to make a match mean something the constructor does not.

Third and most useful, the smart-constructor pattern. Make the constructor private so new is unavailable and expose a validating factory that returns Either or Option. The historical trap is that apply and copy stayed public and both bypassed the validation, so an invalid instance was two characters away. Current Scala propagates the constructor's access modifier to the synthesised apply and copy, which is what makes the pattern actually hold; on older compilers you had to declare a private apply yourself and accept copy as a hole. If the invariant is about the representation rather than construction, an opaque type is usually the cheaper answer.

final case class Email private (value: String)

object Email:
  def parse(s: String): Either[String, Email] =
    if s.contains("@") then Right(Email(s))
    else Left(s"not an email: $s")

One migration detail lives here too: in Scala 2 the companion of a single-parameter-list case class extended AbstractFunctionN, which is why Point.tupled and Point.curried compiled. Scala 3 dropped that inheritance, so those become Point.apply.tupled. General companion mechanics are in companion objects.

Case objects, sealed parents, and exhaustivity

A case object is the zero-field member of the family: it gets the readable toString, the Product members and Serializable, but no apply, unapply or copy, because there is nothing to build or take apart. Its equality is reference equality, which is what you want for a singleton, and serialisation needs a resolve hook so a round trip returns the one instance rather than a second copy - handling that is part of what the modifier buys.

The payoff arrives when case classes and case objects sit under a sealed parent. Sealing restricts direct subclasses to the same source file, so at every match site the compiler knows the complete variant list and can report the branch you left out, naming it. This, not brevity, is the reason to reach for case: add a variant a year later and the compiler enumerates every place that has to change, instead of a MatchError doing it in production.

Three things defeat the check and are worth recognising. The check produces a warning, not an error, so it is only load-bearing if the build fails on warnings. A guard disqualifies a branch from covering its variant, so case Circle(r) if r > 0 leaves Circle officially uncovered. And matching a scrutinee typed Any, or one whose variants differ only in an erased type parameter, gives the compiler nothing to be exhaustive about. Hierarchy design itself is sealed traits and ADTs.

Case class + sealed traitcase classauto equals + copySealed traitclosed hierarchyPattern matchexhaustive checkTogether give algebraic data types with compile-time exhaustive matching
The sealed parent closes the variant set; the generated unapply on each child is what the match reads; together they make the omitted case a compile-time event.
Advertisement

Pattern matching beyond the simple case

Once a type has an unapply, matching composes. The @ operator binds a name to a value and keeps destructuring it, which is how you avoid rebuilding what you just took apart:

events match
  case batch @ Batch(id, (first @ Event(_, Failed(code))) :: rest) if code >= 500 =>
    retry(batch, first, rest.size)          // batch and first are both bound
  case Batch(_, Nil) => ()
  case other          => log(other)

Extractors are not limited to case classes, and this is the part most worth learning. Any object with a suitably shaped unapply becomes a pattern, with no relationship to the type it matches. There are three useful return shapes: Boolean for a pure test, used as case Palindrome() =>; Option[T] to produce one bound value; and Option[(A, B)] for several. A separate method, unapplySeq, returns a sequence and enables the variable-length form, so case Csv(head, tail @ _*) binds the first column and the rest. That lets parsing, validation and normalisation present themselves as patterns over types you do not own - a String, a java.time.Instant, a protobuf message.

Two costs follow from extractors being real method calls. They run at match time, once per pattern the scrutinee reaches, so a match with five extractor branches over the same value may call five methods before it commits; an expensive unapply in a hot match is a genuine performance bug. And a pattern in a val definition is a match with exactly one branch: val Point(x, y) = p is fine because it cannot fail, but val Some(v) = lookup(k) throws a MatchError at runtime from a line that reads like an assignment. Scala 3 requires the pattern be irrefutable there unless you opt in explicitly, which turns that class of bug into a compile error. Pattern matching as a control structure in general is covered in the Scala overview.

What a case class costs

The construct is convenient, not free, and the bill has three separate lines.

Bytecode and class count. Each case class carries roughly a dozen extra methods, plus the synthetic default-argument methods, plus a companion class file holding the singleton. A domain model of three hundred case classes is six hundred classes and several thousand methods that all have to be verified and loaded. That shows up as JVM startup and class-loading time, as metaspace footprint, and on constrained platforms as method-count pressure. Compile time pays too: synthesis and exhaustivity analysis are real compiler work per class.

Product boxing. productElement(i) is typed to return Any, so any library that walks your class generically - a debug printer, a naive CSV or row encoder, some reflective mappers - boxes every primitive field on every element read. The typed accessors do not box; the generic interface always does. If a generic walk sits in a hot path, that is where the allocation is coming from.

Recomputed hashing. No hash is memoised, so a case class used as a map key in a tight loop rehashes every field on every lookup. For a key with one String field this is negligible; for a ten-field key hammered per request it is measurable, and the fix - a class with a cached hash - costs you the modifier.

Arity is a related historical constraint: because unapply handed back a TupleN and the tuple family stops at twenty-two, case classes were long capped there. Later compilers accept wider constructors, but the tuple-shaped extractor degrades, and in practice a twenty-two-field case class is a modelling problem before it is a compiler one.

Binary compatibility - the constraint nobody plans for

Adding a field to a published case class looks additive and is binary-breaking. The constructor signature changes, apply changes, copy changes, unapply's return type changes, and the synthetic copy$default$N methods are part of the ABI as well. Code compiled against the previous version does not fail to compile - it fails at runtime with NoSuchMethodError, typically far from the library that moved. Giving the new parameter a default value fixes source compatibility and does nothing at all for binary compatibility, which is the specific trap, because the source-level fix looks like it solved the problem.

This is why libraries that care about compatibility either freeze their case classes at version one or keep them off the published surface entirely: a sealed parent with the concrete shapes hidden behind constructors, or a plain final class with a hand-written companion, leaves room to add state later. Migration checkers exist largely to catch this category before release. Inside a single application built as one unit the constraint does not apply, which is the right instinct - case is cheap for data that never crosses a version boundary and expensive for data that does.

When not to reach for one

Entities with identity. A row with a database-assigned id has identity semantics: two loads of the same id are the same entity even if a column drifted, and the entity is not equal to a modified copy of itself. Structural equality asserts the opposite of both. Persistence layers make this worse - lazy proxies and generated equals interact badly - so entities want a hand-written equals over the id, or no equals at all.

Anything holding a secret. The generated toString prints every field, so case class Credentials(user: String, password: String) puts the password into every log line, exception message and error report that interpolates the object. Overriding toString keeps the rest of the generated members and is the minimum fix; wrapping the secret in a type whose own toString redacts is the version that survives the next field being added.

Anything that wants inheritance. A case class may not extend another case class, precisely because the generated equality could not stay symmetric. A plain class extending a case class is allowed and produces exactly that asymmetry. If the design needs an open hierarchy with shared state, the modifier is fighting you: use a trait for the interface and plain classes underneath.

Wide records, mutable fields, arrays. Past a dozen fields, positional patterns stop being readable and copy call sites stop being reviewable; nest the record instead. Mutable fields and array fields fail for the equality reasons above. On the JVM, Java's record occupies a similar niche with a narrower feature set.

Scala 3 - case in enums, and the loosened rules

Scala 3's enum reuses this machinery rather than replacing it. A parameterised case inside an enum desugars into what is effectively a case class extending a sealed abstract parent, with the same apply, unapply, equals and toString; a parameterless case becomes a singleton value like a case object. On top you get ordinal, a values array over the parameterless cases, and a derives clause that synthesises type class instances from the compiler's structural mirror. For a closed variant set this is now the shorter spelling of the same thing:

enum Shape derives Encoder:
  case Circle(radius: Double)
  case Rect(w: Double, h: Double)
  case Empty                     // singleton, like a case object

val s: Shape = Shape.Circle(2.0)
s match
  case Shape.Circle(r)  => math.Pi * r * r
  case Shape.Rect(w, h) => w * h
  case Shape.Empty      => 0.0

The rule changes around case class itself are small and each removes a sharp edge. Name-based extraction drops the Option wrapper from the generated unapply. A private constructor now propagates to the synthesised apply and copy, so the smart-constructor pattern no longer leaks. The companion no longer extends a function type, which is the one source-breaking change of the set. And the adaptation that let a single tuple argument stand in for a multi-parameter application is gone, turning a class of silent misuse into a compile error. Enum specifics are in Scala 3 enums; derivation in given derivation.

Case class vs a plain class plus a builder

The honest comparison is not "boilerplate versus no boilerplate" - it is which set of guarantees you want fixed by the compiler and which you want to keep control of.

Requirementcase classplain class + builder
Value equality and hashinggenerated, correct by constructionhand-written, easy to get subtly wrong
Usable as a patternfree, via unapplyonly if you write an extractor
Validation before an instance existsneeds a private constructor and a factorynative to the pattern
Many optional fieldsdefault arguments, or a wide copystaged, readable at the call site
Adding a field without breaking binariesnot possiblepossible, the builder absorbs it
Hiding the internal representationfields are public accessorsfully under your control
Cost to write and maintainone linea class you own forever

What most codebases converge on: case class for data that stays inside one compilation unit and has no invariants beyond its types - requests, events, config records, intermediate results - and a plain final class with a private constructor and a validating companion for values with real invariants or a published surface. The builder proper earns its keep in a narrow band: many optional fields, staged construction, or a Java-facing API. Reaching for a builder because a case class "feels too magical" costs you the extractor and the exhaustivity check, which are the two things worth having.

The case modifier buys an apply/unapply pair, structural equals and hashCode, a printing toString, a named-parameter copy, and Product plus Serializable - and each of those is ordinary generated code with ordinary consequences. Structural equality is the sharp edge: a var field breaks hash-map membership, an array field compares by reference, and a NaN makes an instance unequal to itself. The real payoff is not brevity but exhaustivity - case children under a sealed parent make the branch you forgot a compile-time event - so the modifier earns its place where a closed set of variants is matched on, and earns it least on entities with identity, on anything holding a secret that toString will print, and on types published across a version boundary where adding one field is a binary break.