You need a value type that guarantees "start is never after end", and you write it with your language's boilerplate generator — a Java record, a Kotlin data class, a Scala case class, a C# record, a Python frozen @dataclass. Each of these also hands callers a way to produce a modified copy (copy(...), the C# with expression, dataclasses.replace). Before you trust such a type to hold that rule, which construction paths would you enumerate, and where do these languages genuinely diverge?
answer
- the generator adds a constructor you did not write
- C# with = clone + init setters, no body runs
- Kotlin/Scala copy() calls the primary constructor
- Java record: no copy at all, but aliased arrays
- frozen blocks rebinding, not mutation
basics
~20 sEnumerate every construction path the generator adds, not just the one you wrote. C# with-expressions clone the fields and assign through init setters, so no constructor body re-runs; Kotlin and Scala copy() re-runs the primary constructor; Java records add no copy path at all.
solid answer
~50 sAn invariant is only as strong as the weakest path that can produce an instance — and the generator adds paths you did not write. - **Kotlin, Scala, Python**: `copy()` / `dataclasses.replace()` routes through the primary constructor, so `init` blocks, `require`, and `__post_init__` re-run. The leak is *visibility* — a private constructor plus a validating factory is bypassed by a still-public generated `copy` (hence Kotlin's `@ConsistentCopyVisibility`, and Scala 3 tightening the synthesized `apply`/`copy`). - **C#**: `with` lowers to the compiler-generated clone member — a field-wise copy plus assignment through `init` accessors. No constructor body runs, and that clone member is public and not user-declarable, so the path cannot be closed. A cross-field check inside an `init` accessor sees half-updated state, so a two-field invariant cannot hold there at all. - **Java**: no copy is generated and every constructor must delegate to the canonical one, so a compact constructor's check cannot be dodged. Its leak is depth — array and mutable-collection components stay aliased with the caller.
code
text · 14 linesPATH A (you wrote it) PATH B (the generator wrote it)
construct(1, 5) derived-copy of original, end := 0
| |
v v
constructor body field-by-field copy of the source
require(start <= end) |
| v
v assign only the named fields
validated instance |
v
C# (with): no constructor body ran -> UNVALIDATED
Kotlin/Scala/ copy()/replace() calls the primary
Python: constructor -> body re-runs -> validated
Java: this path does not existgo deeper
Know that these generators also generate a copy operation, and that "immutable" here means a component cannot be reassigned — not that an object it points at cannot change.
Be able to say which languages re-run the constructor on copy (Kotlin, Scala, Python) and which do not (C#), and where the validation has to live in each so it is not skipped.
Give the checklist — paths, depth, visibility — and name the two concrete divergences: C#'s clone-plus-init-setter path that no constructor body sees, and the Kotlin/Scala public-copy-around-a-private-constructor leak with its fix.
Frame it as invariant strength versus generated ergonomics: decide explicitly whether to weaken the rule to something every generated path preserves or to abandon generation for factories, and account for serialization paths and what the team will reach for by default.
## An invariant is a property of paths, not of a type A *business invariant* is a rule that must hold for every instance that exists: "start is never after end", "the currency code is one of a known set", "the item list is never empty". You enforce it by checking it wherever an instance can come into existence, and then making the instance immutable so the checked state cannot be invalidated afterwards. That makes enforcement a mechanical question: **enumerate every path that can produce an instance, and confirm the check runs on each one.** A type with four construction paths and validation on three of them has no invariant — it has a convention. The trouble with generated value types — Java's `record`, Kotlin's `data class`, Scala's `case class`, C#'s `record`, Python's `@dataclass` — is that the generator adds paths. You wrote one constructor; the compiler may have added another, and the added one is the one a future maintainer will reach for. ## What the generator actually adds All of these features remove the same drudgery: a constructor, component-wise equality, a hash, a printable form, and — the part that matters here — a *derived-copy* operation that produces a new instance identical to an existing one except for the components you name. Kotlin and Scala spell it `copy(...)`; C# spells it `x with { ... }`; Python spells it `dataclasses.replace(x, ...)` (and, since 3.13, `copy.replace`). Java spells it nothing: records generate no copy operation at all. That copy operation is a second constructor under another name, and the languages implement it in two fundamentally different ways. ## Family 1 — route through the constructor: Kotlin, Scala, Python Kotlin's generated `copy()` is compiled as a call to the *primary constructor*, passing the unchanged components as defaults. Everything the primary constructor does — including `init { require(start <= end) }` — runs again on every copy. Scala behaves the same way: statements in a case-class body are part of its constructor, so a `require` re-runs on `copy`. Python's `dataclasses.replace()` calls `__init__`, therefore `__post_init__`, therefore your validation. The leak in this family is not validation but *visibility*. The classic hardening idiom is a private constructor plus a validating static factory, so that the only way in is through the factory. Kotlin generated `copy()` as public even when the primary constructor was private, letting callers route around the factory; Kotlin 2.0.20 added `@ConsistentCopyVisibility` (with `@ExposedCopyVisibility` as the explicit opt-out) to align the two, on the way to making alignment the default. Scala 2 had the same wart with the synthesized `apply` and `copy` of a case class whose constructor was private; Scala 3 tightened it. Python's `replace()` will also happily rebuild a frozen instance, and `object.__setattr__` bypasses `frozen=True` entirely. ## Family 2 — copy the fields: C# C# does not route `with` through any constructor whose body you can write. `x with { End = 0 }` is lowered to a call to the compiler-generated clone member, which invokes the record's copy constructor — a member-wise field copy — followed by assignment through the `init` accessors of the components you named. No constructor body executes. Imperative validation therefore protects the first instance and nothing derived from it. Two facts sharpen this. First, a *positional* record such as `record Range(int Start, int End)` has no canonical-constructor body in which to put a check at all: you may not declare a constructor duplicating the primary parameter list, so validation must live in a property initializer or an `init` accessor from the start. Writing the type in non-positional form with an explicit validating constructor does give you a body — and `with` still skips it. Second, you cannot close the copy path: the clone member the compiler emits is public and not user-declarable (C# even forbids declaring a member named `Clone` on a record), so making the copy constructor private does not hide `with`. That leaves the `init` accessor, which works for single-component checks and *fails for cross-component ones*. During a `with`, the clone starts with the old values and the named assignments happen in order, so a check in `Start`'s accessor compares the new `Start` against the **old** `End`. `original with { Start = 9, End = 20 }` can be rejected even though the final state is valid — and the mirror case lets an invalid intermediate through when the order runs the other way. The honest conclusion is that a two-field invariant cannot be enforced on all of a C# record's paths: validate where the value enters the domain, or drop the record for a class with a private constructor and named factories. ## Depth and rebinding Every language here gives you shallowness for free. Java records are final and their fields cannot even be written by reflection, but a component of array or mutable-collection type is stored as handed in and handed back out, so callers on both sides hold a live alias — copy defensively on the way in and on the way out. Python's `frozen=True` only overrides attribute assignment to raise; a `list` field stays fully mutable. "Immutable" in all of these languages means *this reference cannot be rebound*, never *this object graph cannot change*. ## The checklist Three questions. **Paths**: which constructors exist — the one you wrote, the generator's copy, and the one your serialization framework uses — and does the check run on every one? **Depth**: is any component an array, a mutable collection, or a reference to a mutable object? **Visibility**: if the invariant is enforced by a factory rather than a constructor, is there a public copy path around the factory? ## The call Where the generator cannot hold the rule, choose deliberately: weaken the invariant to something every generated path preserves (usually right for purely structural rules), or give up generation for a hand-written type with a private constructor and named factories (usually right when violating the rule has real business consequence). What is never acceptable is documenting the rule in a comment and trusting the copy path to be used politely — that path is exactly the one the next maintainer will reach for.
- The rule is "start <= end", the type must be a C# record, and callers must keep using the `with` expression. What do you actually do?Accept that the type cannot enforce a cross-field rule on the copy path and move enforcement elsewhere: validate at the boundary where the value enters the domain, or expose a validating method for the update instead of `with`. If the rule must be structurally guaranteed, stop using a record — use a class with a private constructor and named factories, which costs you `with` and the generated equality, and write those by hand.
- How does serialization change the analysis?It adds a construction path you did not write. Java record deserialization is required to go through the canonical constructor, so compact-constructor checks still hold, and most record-aware frameworks bind through it too. But reflection- or Unsafe-based deserializers that allocate an instance and write fields directly bypass every constructor in any of these languages, which reopens the hole. Verify how your specific serializer builds the type rather than assuming.
- Why doesn't making the copy constructor private stop C#'s `with`?`with` is not lowered to a call to the copy constructor from the call site; it is lowered to a call to the compiler-emitted clone member, which is public and cannot be declared or hidden by the author. That clone member calls the copy constructor from inside the type, where a private member is perfectly accessible. So the accessibility of the copy constructor changes what subclasses and outside code can call directly, but not whether `with` compiles.
You guarded the front door and wrote the rule on it. The generator then installed a loading dock at the back that lets anyone assemble a near-identical instance without walking past the guard.
saying these in an interview costs you the question
- "It's a record / data class, so it's immutable" — the guarantee is only that components cannot be rebound; an array or mutable-collection component is still shared with the caller.
- "Validation in the constructor covers every instance" — it covers the paths that run a constructor, which excludes C#'s `with`.
- "C#'s `with` calls the constructor again" — it calls the generated clone member and then the init accessors.
- "A private constructor plus a static factory is airtight" — in Kotlin and Scala 2 the generated `copy` (and Scala's `apply`) stayed public and routed around the factory.
- "Kotlin's `copy()` skips validation like C#'s `with`" — the opposite: `copy()` compiles to a primary-constructor call, so `init` blocks re-run.