A private constructor on a Kotlin data class doesn’t stop callers from building new instances. The compiler still generates a public copy(). It calls that private constructor for anyone who holds an instance. So a factory that validates input guards only the first object. Every copy after it skips the factory. Kotlin 2.0.20 started warning about this. Kotlin 2.4.20 still only warns. Its compiler message says the warning becomes an error in language version 2.5. A second cause sits under the first. The rules about what counts as a valid value live in the factory, not in the type. That’s why closing copy() alone isn’t enough. The fix has two parts. Annotate the class with @ConsistentCopyVisibility so the data class copy matches the constructor’s visibility. Then move every invariant into an init block, so every constructor call checks it. That includes the class’s own calls to copy().
An Email value object that a caller can forge
The running example is an Email type. The only way in is Email.of(). It trims and lowercases the raw input, checks the format and returns null when the check fails. The constructor is private, so the author believes every Email passed through that check.
data class Email private constructor(val value: String) {
companion object {
private val EMAIL = Regex("^[^@\\s]+@[^@\\s]+\\.[a-z]+$")
fun of(raw: String): Email? {
val normalized = raw.trim().lowercase()
return if (EMAIL.matches(normalized)) Email(normalized) else null
}
}
}
fun main() {
val email = Email.of(" [email protected] ")!!
println(email)
val forged = email.copy(value = "not an email")
println(forged)
}
Compiled with kotlinc 2.4.20 and run on the JVM, it prints this:
Email([email protected]) Email(value=not an email)
The build passes. It reports two warnings, one on the class and one on the call site. The first starts with “non-public primary constructor is exposed via the generated ‘copy()’ method.” A warning is easy to miss in a long build log, so the forged value can ship.
Root cause 1: the data class copy() ignores the constructor’s visibility
The data classes docs show what the compiler generates. For a class User(name, age), copy() is a function with default arguments that returns User(name, age). It’s an ordinary call to the primary constructor. By default, the compiler gives that function public visibility, whatever the constructor’s visibility is.
The bug report is KT-11914. It was filed in April 2016 against Kotlin 1.0.1. The problem is nearly as old as the language. The issue says the language committee agreed to treat the current behavior as a design bug. The fix ships in phases, because changing copy()‘s visibility changes the binary signature that compiled callers link against.
- In Kotlin 2.0.20, the compiler started to warn. The What’s new in Kotlin 2.0.20 page (released August 2024) describes the warning and the two new annotations.
- In Kotlin 2.5 and later, KT-11914 says the warnings turn into errors. The 2.4.20 compiler message points to language version 2.5 as well.
- In Kotlin 2.6 and later, KT-11914 says the default changes, so
copy()takes the constructor’s visibility. The issue adds that these versions aren’t final yet.
The @ConsistentCopyVisibility API page still names older guesses of “Kotlin 2.1 or Kotlin 2.2” for the errors. It defers to KT-11914 for the exact versions, so trust the issue. You can see the next phase today. Compiling the snippet above with -language-version 2.5 turns both warnings into errors. Version 2.5 is experimental in this compiler, so treat that as a preview.
Root cause 2: the invariant lives in the factory, not in the type
The obvious repair is to validate in an init block. Because copy() calls the constructor, the block runs on every copy too. That stops the forged "not an email" value. But this class has two rules. The value must match the format. It must also be trimmed and lowercased. The second rule still lives only in of().
data class Email private constructor(val value: String) {
init {
require(EMAIL.matches(value)) { "Invalid email: $value" }
}
companion object {
private val EMAIL = Regex("^[^@\\s]+@[^@\\s]+\\.[a-z]+$")
fun of(raw: String): Email? =
runCatching { Email(raw.trim().lowercase()) }.getOrNull()
}
}
val email = Email.of(" [email protected] ")!!
println(runCatching { email.copy(value = "not an email") })
val sneaky = email.copy(value = "[email protected]")
println(sneaky)
println(sneaky == Email.of("[email protected]"))
println(setOf(email, sneaky).size)
Failure(java.lang.IllegalArgumentException: Invalid email: not an email) Email([email protected]) false 2
The format check now holds. The normalization rule doesn’t. sneaky is a valid address that isn’t equal to the same address built through of(). A set now holds two entries for one person. A lookup in a map keyed by Email misses. Code that relied on the factory’s guarantee is now wrong. No exception points at the cause.
The general rule is that anything the factory guarantees must also be checked where every instance is born. For a data class, that place is the primary constructor. Its init block runs as part of it. A factory can still prepare input, as of() does when it lowercases. But the type itself must reject anything the factory would never produce.
The fix: match copy() to the constructor and check every rule in init
@ConsistentCopyVisibility
data class Email private constructor(val value: String) {
init {
require(value == value.trim().lowercase()) { "Not normalized: $value" }
require(EMAIL.matches(value)) { "Invalid email: $value" }
}
fun withDomain(domain: String): Email =
copy(value = value.substringBefore('@') + "@" + domain.trim().lowercase())
companion object {
private val EMAIL = Regex("^[^@\\s]+@[^@\\s]+\\.[a-z]+$")
fun of(raw: String): Email? =
runCatching { Email(raw.trim().lowercase()) }.getOrNull()
}
}
fun main() {
val email = Email.of(" [email protected] ")!!
println(email)
println(email.withDomain(" GrindLoop.io "))
println(runCatching { email.withDomain("bad domain") })
println(Email.of("not an email"))
}
Email([email protected]) Email([email protected]) Failure(java.lang.IllegalArgumentException: Invalid email: ana@bad domain) null
@ConsistentCopyVisibilitymakes the generatedcopy()private, like the constructor. The API page says it opts the class into the future behavior now and silences the warnings. An outside call toemail.copy(...)no longer compiles. With 2.4.20 the error reads “cannot access ‘fun copy(value: String = …): Email’: it is private in ‘Email’.”- The two
requirecalls ininitrun on every construction. So the class’s owncopy()calls get checked too. A bug insideEmailfails loudly instead of creating a bad value. withDomain()replaces the publiccopy()with a named change that keeps the rules. Callers lose the genericcopy()on purpose. It could set any field to anything.
To apply the same rule to a whole module, the 2.0.20 release notes describe the -Xconsistent-data-class-copy-visibility compiler flag. It has the same effect as annotating every data class in the module.
For a single-field wrapper like Email, a value class is another option. A @JvmInline value class gets no generated copy(). With 2.4.20, calling copy on one fails with “unresolved reference ‘copy’.” The init rule still applies there. A private constructor plus a validating init gives the same guarantee.
@ExposedCopyVisibility only quiets the declaration
The second annotation from 2.0.20 is @ExposedCopyVisibility. It keeps copy() public in the compiled binary. The API page says illegal uses of copy() will become inaccessible anyway. It also says the call-site warning remains even with the annotation. A test class with @ExposedCopyVisibility shows exactly that on 2.4.20. The class warning goes away. The call-site warning stays. An outside copy() call still builds a forged Email(value=x).
The page recommends @ConsistentCopyVisibility for new code. @ExposedCopyVisibility is for a published library with existing callers. Those callers were compiled against the public copy(). The library must not break them. The page asks authors to drop it once those calls are migrated. In an app module, there’s rarely a reason to use it.
How to walk an interviewer through this snippet
A technical round can frame this snippet as a question. Is the type safe to construct? It also fits a code review round. There, a private constructor with a factory looks finished at first glance. The Android code review interview post covers how to rank an issue like this in a pull request.
- Point at
copy()first. A data class generates it to call the primary constructor. Until the default changes, it’s public. Anyone holding anEmailcan create another one with any value. - Name the version context. Kotlin 2.0.20 warns about it. Kotlin 2.5 is slated to make it an error. The default flips after that. Cite KT-11914 if asked. That shows you know the language changed, not just the trick.
- Then name the deeper problem. The factory holds the rules, so any path that skips the factory skips the rules. Closing
copy()fixes this path but not the design. - Give the fix. Add
@ConsistentCopyVisibility, put every invariant ininitand expose named methods likewithDomain()for allowed changes. Mention a value class as the single-field alternative.
Step 3 shows you know where invariants belong. Naming the annotation alone doesn’t show that. The same class has another trap worth knowing. Properties outside the primary constructor, including a parent class’s, are left out of equals() and copy(). See why a data class’s equals() ignores the parent class.
Wrong answers that sound reasonable
- “The constructor is private, so only the factory can create instances.” That’s the bug.
copy()is a second way in. - “
copy()clones the object, so it skipsinit.” It doesn’t. The data classes docs showcopy()calling the primary constructor. The second run above showsinitrejecting a bad copy. - “Override
copy()and make it private.” The data classes docs say explicitcopy()implementations aren’t allowed. With 2.4.20, declaring one fails with “conflicting overloads.” - “Add
@ExposedCopyVisibilityto silence it.” That hides the declaration warning and keeps the hole open. The call-site warning stays. - “Validate in
initand you’re done.” It only protects the rules you put there. The normalization rule in this example lived only inof(), so"[email protected]"got through and broke equality.