Several languages generate equality and hashing for you from a type's declared members — Kotlin data classes, Scala case classes, C# records and Java records among them. Compare what these generated implementations actually include, and where each one still leaks.
answer
- Three axes: member list, subtype policy, member semantics
- Kotlin: constructor properties only — body properties invisible
- Scala canEqual vs C# runtime equality contract
- Arrays compare by identity in every generator
- Generation gives the contract, not immutability
basics
~20 sThey differ on three axes: which members count (Kotlin uses primary-constructor properties only; C# records use all instance fields), the subtype policy (Scala's canEqual, C#'s runtime equality contract, final Java records), and reference members such as arrays, which all four compare by identity.
solid answer
~50 sGenerated equality is not one feature; it is several different policies wearing the same name. - **Which members participate** — a Kotlin data class uses *only* primary-constructor properties, so a property declared in the class body is silently excluded from equality, hashing and `copy`. A Scala case class uses the first parameter list only. A C# record compares all instance fields, including body-declared auto-properties. A Java record compares exactly its components, and the language forbids extra instance fields. - **Subtype boundaries** — Java records and Kotlin data classes are final. Scala emits `canEqual`, so a refining subtype can add state and stay symmetric. A C# record emits a virtual equality-contract property carrying the runtime type, so a derived record is never equal to its base. - **Per-member semantics** — arrays and other identity-compared members compare by reference in all four, so a generated "value type" holding an array is not a value type.
code
text · 6 linesdeclare: Point(x, y) { z } // z moved out of the constructor list into the body
Kotlin data class : equality = {x, y} -> z invisible; semantic change
Scala case class : equality = {x, y} -> z invisible; semantic change
C# record : equality = {x, y, z} -> z still counted; no-op
Java record : does not compile -> extra instance field forbiddengo deeper
Know that these languages generate equals and hashCode together from a declared member list, so the two can never disagree, and that the member list is not always every field.
Name the three axes — member list, subtype policy, per-member comparison — and give at least one concrete divergence per axis, such as Kotlin's constructor-only rule versus C# records counting all instance fields.
Turn it into review practice: which refactors silently change a value's meaning, why a mutable or array member defeats generation, and what breaks when a generated key is stored in a hashed container.
Frame it as a policy decision for a polyglot codebase — pick one identity policy per value type, decide whether subtyping over value types is allowed at all, and treat generated equality as a default that still needs an explicit review question per type.
## What generated equality actually promises A generated equality implementation derives "are these two instances the same value?" mechanically from a list of members, and derives the hash from the same list. That mechanical link is the real benefit: the two operations cannot drift apart, because one declaration produces both, so the law *equal objects must hash alike* is upheld by construction. What generation does **not** promise is that any two languages pick the same member list, the same subtype policy, or the same per-member comparison. Those three choices are where portable-looking value types stop behaving the same across a polyglot codebase. ## Axis 1 — which members are in the list Kotlin data classes take the properties of the **primary constructor only**. A property declared in the class body is not part of equality, hashing, `toString` or `copy`. This is deliberate — the constructor list *is* the value's identity — but it is the single most common surprise: someone moves a field into the body to give it a default or a custom getter, and two objects that differ in that field silently become equal. Because hashing is derived from the same list, the two also hash alike, so nothing ever throws. The bug is a wrong answer, never an exception. Scala case classes take the **first parameter list**; a second (curried or implicit) parameter list is excluded, with the same silent consequence. C# records take **all instance fields**, including the backing fields of stored properties declared in the body — a strictly more inclusive policy than Kotlin's. The same refactor that changes meaning in Kotlin changes nothing in C#. (A computed, backing-field-less property is not stored state and so is invisible everywhere.) Java records take exactly the record components, and the language forbids additional instance fields, so the list cannot drift — the refactor is not merely a no-op, it does not compile. So one refactor — "move this member out of the constructor list" — is a semantic change in two of these four languages, a no-op in the third, and rejected by the fourth. ## Axis 2 — what happens across a subtype boundary Extending a type that defines value equality is the classic trap: a lenient check breaks symmetry (base considers itself equal to a derived instance, the derived instance disagrees), while an exact-runtime-type check preserves symmetry at the cost of substitutability. The four generators take four different exits. - **Java records** and **Kotlin data classes** are final — a data class cannot be declared open — so the language removes the problem by removing the extension point. - **Scala** generates `canEqual`, a member that each side calls on the other. Both operands must agree to be compared, so a refining subtype that adds state can override `canEqual` and remain symmetric; the language ships the standard hand-written workaround as generated code. Inheriting one case class from another is prohibited precisely because the generated members would no longer compose. - **C# records** generate a virtual equality-contract property returning the runtime type, and generated equality compares contracts first. Two records of different runtime types are therefore never equal in either direction: symmetry and transitivity hold by construction, at the cost that a derived record can never equal a base record even when every field matches. C# also generates a virtual clone so that `with`-expressions through a base-typed reference preserve the derived type. The Kotlin leak here is subtler than "data classes are final": a data class *may* inherit from an open class, and the generated equality still looks only at its own constructor properties. Inherited state is invisible to equality and hashing, and the generated copy reconstructs the object through the primary constructor, dropping whatever the base initialised differently. ## Axis 3 — how each member is compared Every generator compares a member by delegating to that member's own equality. For reference-typed members whose own equality is identity — arrays being the universal example — the generated "value equality" degenerates into identity equality for that field. Two instances holding element-wise identical arrays are unequal, and a hash lookup with equal contents misses. All four share this trap; none deep-compares. Floating-point members are the mirror image. Generated equality typically routes through a total-order comparison rather than the primitive IEEE-754 operator, so a not-a-number member can compare equal to itself inside a generated value type while the raw comparison operator says otherwise — and the languages do not even agree with each other about negative zero. Do not assume the generated implementation and a hand-written field-by-field comparison agree on these members. Mutability is the last gap. Generation freezes nothing: a Kotlin data class may declare mutable properties, and a mutable member of any of these types stays mutable. Generation gives you the *contract*; it does not give you the immutability that makes the contract safe to rely on inside a hashed container, where a member mutated after insertion moves the key's hash and strands the entry. ## How to use this Treat generation as a good default with three review questions: is every field that defines the value actually in the generated list; is the type final or does the language have a subtype story; and does any member compare by identity where you meant by content. The three questions are language-independent even though every answer is language-specific.
- A generated value type is used as a key in a hashed container and one of its members is a mutable list. What goes wrong, and does generation help or hurt?Generation makes the failure more likely, not less: it silently includes the mutable member in both equality and the hash, so mutating the list after insertion changes the stored key's hash and the entry becomes unreachable even though the object is still in the table. A hand-written implementation at least forces an author to look at each field and decide. The fixes are to make the member deeply immutable at construction, or to exclude it from the value's identity — which several of these languages cannot express without abandoning generation entirely.
- C# records make a derived record never equal to a base record, because generated equality compares the runtime-type equality contract first. What does that cost?It costs substitutability. Any legitimate subtype — an instrumented, proxied or lazily-loading variant — stops matching the base value it represents, so set membership and map lookups silently fail even when every field agrees. It is the same trade a hand-written exact-runtime-type check makes: symmetry and transitivity are guaranteed, but the equivalence relation is no longer defined over the base type's whole value space. Scala's canEqual takes the other exit, keeping cross-type equality possible when both operands consent.
- If a Kotlin data class and a Java record both generate equals and hashCode, why can only one of them break the hash law?Neither breaks the law that equal objects hash alike — both derive the hash from the same member list they compare, so the two can't disagree. What differs is the member list's completeness: a Kotlin data class can inherit state from an open base class, or declare properties in its body, and neither participates. The result is objects that are genuinely different values yet equal and equally hashed — a wrong answer inside a consistent contract, which is far harder to spot than a contract violation.
saying these in an interview costs you the question
- Saying generated equality implies immutability, or that a generated value type is automatically safe as a hash key
- Assuming all four languages include the same members — most notably that a Kotlin data class counts body-declared properties the way a C# record counts stored ones
- Claiming generated equality deep-compares arrays or other collection-typed members
- Believing a Kotlin data class cannot participate in inheritance at all, so inherited state cannot be silently dropped from equality
- Treating Scala's canEqual and C#'s runtime equality contract as the same mechanism — one permits cross-type equality by mutual consent, the other forbids it outright