{"id":735,"date":"2026-10-03T14:15:00","date_gmt":"2026-10-03T12:15:00","guid":{"rendered":"https:\/\/grindloop.io\/blog\/?p=735"},"modified":"2026-10-03T13:39:30","modified_gmt":"2026-10-03T11:39:30","slug":"kotlin-data-class-copy-private-constructor","status":"publish","type":"post","link":"https:\/\/grindloop.ai\/blog\/kotlin-data-class-copy-private-constructor\/","title":{"rendered":"Why a Private Constructor Doesn&#8217;t Stop Kotlin Data Class copy()"},"content":{"rendered":"<p>A private constructor on a Kotlin data class doesn&#8217;t stop callers from building new instances. The compiler still generates a public <code>copy()<\/code>. 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&#8217;s why closing <code>copy()<\/code> alone isn&#8217;t enough. The fix has two parts. Annotate the class with <code>@ConsistentCopyVisibility<\/code> so the data class copy matches the constructor&#8217;s visibility. Then move every invariant into an <code>init<\/code> block, so every constructor call checks it. That includes the class&#8217;s own calls to <code>copy()<\/code>.<\/p>\n\n<h2>An Email value object that a caller can forge<\/h2>\n\n<p>The running example is an <code>Email<\/code> type. The only way in is <code>Email.of()<\/code>. 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 <code>Email<\/code> passed through that check.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">data class Email private constructor(val value: String) {\n    companion object {\n        private val EMAIL = Regex(\"^[^@\\\\s]+@[^@\\\\s]+\\\\.[a-z]+$\")\n\n        fun of(raw: String): Email? {\n            val normalized = raw.trim().lowercase()\n            return if (EMAIL.matches(normalized)) Email(normalized) else null\n        }\n    }\n}\n\nfun main() {\n    val email = Email.of(\"  Ana@Example.COM \")!!\n    println(email)\n    val forged = email.copy(value = \"not an email\")\n    println(forged)\n}<\/pre>\n\n\n<p>Compiled with <code>kotlinc<\/code> 2.4.20 and run on the JVM, it prints this:<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">Email(value=ana@example.com)\nEmail(value=not an email)<\/pre>\n\n\n<p>The build passes. It reports two warnings, one on the class and one on the call site. The first starts with &#8220;non-public primary constructor is exposed via the generated &#8216;copy()&#8217; method.&#8221; A warning is easy to miss in a long build log, so the forged value can ship.<\/p>\n\n<h2>Root cause 1: the data class copy() ignores the constructor&#8217;s visibility<\/h2>\n\n<p>The <a href=\"https:\/\/kotlinlang.org\/docs\/data-classes.html\" target=\"_blank\" rel=\"noopener\">data classes docs<\/a> show what the compiler generates. For a class <code>User(name, age)<\/code>, <code>copy()<\/code> is a function with default arguments that returns <code>User(name, age)<\/code>. It&#8217;s an ordinary call to the primary constructor. By default, the compiler gives that function public visibility, whatever the constructor&#8217;s visibility is.<\/p>\n\n<p>The bug report is <a href=\"https:\/\/youtrack.jetbrains.com\/issue\/KT-11914\" target=\"_blank\" rel=\"noopener\">KT-11914<\/a>. 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 <code>copy()<\/code>&#8216;s visibility changes the binary signature that compiled callers link against.<\/p>\n\n<ul>\n<li>In Kotlin 2.0.20, the compiler started to warn. The <a href=\"https:\/\/kotlinlang.org\/docs\/whatsnew2020.html\" target=\"_blank\" rel=\"noopener\">What&#8217;s new in Kotlin 2.0.20<\/a> page (released August 2024) describes the warning and the two new annotations.<\/li>\n<li>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.<\/li>\n<li>In Kotlin 2.6 and later, KT-11914 says the default changes, so <code>copy()<\/code> takes the constructor&#8217;s visibility. The issue adds that these versions aren&#8217;t final yet.<\/li>\n<\/ul>\n\n<p>The <a href=\"https:\/\/kotlinlang.org\/api\/core\/kotlin-stdlib\/kotlin\/-consistent-copy-visibility\/\" target=\"_blank\" rel=\"noopener\"><code>@ConsistentCopyVisibility<\/code> API page<\/a> still names older guesses of &#8220;Kotlin 2.1 or Kotlin 2.2&#8221; 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 <code>-language-version 2.5<\/code> turns both warnings into errors. Version 2.5 is experimental in this compiler, so treat that as a preview.<\/p>\n\n<h2>Root cause 2: the invariant lives in the factory, not in the type<\/h2>\n\n<p>The obvious repair is to validate in an <code>init<\/code> block. Because <code>copy()<\/code> calls the constructor, the block runs on every copy too. That stops the forged <code>\"not an email\"<\/code> 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 <code>of()<\/code>.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">data class Email private constructor(val value: String) {\n    init {\n        require(EMAIL.matches(value)) { \"Invalid email: $value\" }\n    }\n    companion object {\n        private val EMAIL = Regex(\"^[^@\\\\s]+@[^@\\\\s]+\\\\.[a-z]+$\")\n        fun of(raw: String): Email? =\n            runCatching { Email(raw.trim().lowercase()) }.getOrNull()\n    }\n}\n\nval email = Email.of(\"  Ana@Example.COM \")!!\nprintln(runCatching { email.copy(value = \"not an email\") })\nval sneaky = email.copy(value = \"Ana@Example.com\")\nprintln(sneaky)\nprintln(sneaky == Email.of(\"ana@example.com\"))\nprintln(setOf(email, sneaky).size)<\/pre>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">Failure(java.lang.IllegalArgumentException: Invalid email: not an email)\nEmail(value=Ana@Example.com)\nfalse\n2<\/pre>\n\n\n<p>The format check now holds. The normalization rule doesn&#8217;t. <code>sneaky<\/code> is a valid address that isn&#8217;t equal to the same address built through <code>of()<\/code>. A set now holds two entries for one person. A lookup in a map keyed by <code>Email<\/code> misses. Code that relied on the factory&#8217;s guarantee is now wrong. No exception points at the cause.<\/p>\n\n<p>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 <code>init<\/code> block runs as part of it. A factory can still prepare input, as <code>of()<\/code> does when it lowercases. But the type itself must reject anything the factory would never produce.<\/p>\n\n<h2>The fix: match copy() to the constructor and check every rule in init<\/h2>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">@ConsistentCopyVisibility\ndata class Email private constructor(val value: String) {\n    init {\n        require(value == value.trim().lowercase()) { \"Not normalized: $value\" }\n        require(EMAIL.matches(value)) { \"Invalid email: $value\" }\n    }\n\n    fun withDomain(domain: String): Email =\n        copy(value = value.substringBefore('@') + \"@\" + domain.trim().lowercase())\n\n    companion object {\n        private val EMAIL = Regex(\"^[^@\\\\s]+@[^@\\\\s]+\\\\.[a-z]+$\")\n\n        fun of(raw: String): Email? =\n            runCatching { Email(raw.trim().lowercase()) }.getOrNull()\n    }\n}\n\nfun main() {\n    val email = Email.of(\"  Ana@Example.COM \")!!\n    println(email)\n    println(email.withDomain(\" GrindLoop.io \"))\n    println(runCatching { email.withDomain(\"bad domain\") })\n    println(Email.of(\"not an email\"))\n}<\/pre>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">Email(value=ana@example.com)\nEmail(value=ana@grindloop.io)\nFailure(java.lang.IllegalArgumentException: Invalid email: ana@bad domain)\nnull<\/pre>\n\n\n<ul>\n<li><code>@ConsistentCopyVisibility<\/code> makes the generated <code>copy()<\/code> private, like the constructor. The API page says it opts the class into the future behavior now and silences the warnings. An outside call to <code>email.copy(...)<\/code> no longer compiles. With 2.4.20 the error reads &#8220;cannot access &#8216;fun copy(value: String = &#8230;): Email&#8217;: it is private in &#8216;Email&#8217;.&#8221;<\/li>\n<li>The two <code>require<\/code> calls in <code>init<\/code> run on every construction. So the class&#8217;s own <code>copy()<\/code> calls get checked too. A bug inside <code>Email<\/code> fails loudly instead of creating a bad value.<\/li>\n<li><code>withDomain()<\/code> replaces the public <code>copy()<\/code> with a named change that keeps the rules. Callers lose the generic <code>copy()<\/code> on purpose. It could set any field to anything.<\/li>\n<\/ul>\n\n<p>To apply the same rule to a whole module, the 2.0.20 release notes describe the <code>-Xconsistent-data-class-copy-visibility<\/code> compiler flag. It has the same effect as annotating every data class in the module.<\/p>\n\n<p>For a single-field wrapper like <code>Email<\/code>, a value class is another option. A <code>@JvmInline value class<\/code> gets no generated <code>copy()<\/code>. With 2.4.20, calling <code>copy<\/code> on one fails with &#8220;unresolved reference &#8216;copy&#8217;.&#8221; The <code>init<\/code> rule still applies there. A private constructor plus a validating <code>init<\/code> gives the same guarantee.<\/p>\n\n<h2>@ExposedCopyVisibility only quiets the declaration<\/h2>\n\n<p>The second annotation from 2.0.20 is <code>@ExposedCopyVisibility<\/code>. It keeps <code>copy()<\/code> public in the compiled binary. The <a href=\"https:\/\/kotlinlang.org\/api\/core\/kotlin-stdlib\/kotlin\/-exposed-copy-visibility\/\" target=\"_blank\" rel=\"noopener\">API page<\/a> says illegal uses of <code>copy()<\/code> will become inaccessible anyway. It also says the call-site warning remains even with the annotation. A test class with <code>@ExposedCopyVisibility<\/code> shows exactly that on 2.4.20. The class warning goes away. The call-site warning stays. An outside <code>copy()<\/code> call still builds a forged <code>Email(value=x)<\/code>.<\/p>\n\n<p>The page recommends <code>@ConsistentCopyVisibility<\/code> for new code. <code>@ExposedCopyVisibility<\/code> is for a published library with existing callers. Those callers were compiled against the public <code>copy()<\/code>. The library must not break them. The page asks authors to drop it once those calls are migrated. In an app module, there&#8217;s rarely a reason to use it.<\/p>\n\n<h2>How to walk an interviewer through this snippet<\/h2>\n\n<p>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 <a href=\"https:\/\/grindloop.ai\/blog\/android-code-review-interview\/\">Android code review interview<\/a> post covers how to rank an issue like this in a pull request.<\/p>\n\n<ol>\n<li>Point at <code>copy()<\/code> first. A data class generates it to call the primary constructor. Until the default changes, it&#8217;s public. Anyone holding an <code>Email<\/code> can create another one with any value.<\/li>\n<li>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.<\/li>\n<li>Then name the deeper problem. The factory holds the rules, so any path that skips the factory skips the rules. Closing <code>copy()<\/code> fixes this path but not the design.<\/li>\n<li>Give the fix. Add <code>@ConsistentCopyVisibility<\/code>, put every invariant in <code>init<\/code> and expose named methods like <code>withDomain()<\/code> for allowed changes. Mention a value class as the single-field alternative.<\/li>\n<\/ol>\n\n<p>Step 3 shows you know where invariants belong. Naming the annotation alone doesn&#8217;t show that. The same class has another trap worth knowing. Properties outside the primary constructor, including a parent class&#8217;s, are left out of <code>equals()<\/code> and <code>copy()<\/code>. See <a href=\"https:\/\/grindloop.ai\/blog\/kotlin-data-class-equals-ignores-parent-class\/\">why a data class&#8217;s equals() ignores the parent class<\/a>.<\/p>\n\n<h2>Wrong answers that sound reasonable<\/h2>\n\n<ul>\n<li>&#8220;The constructor is private, so only the factory can create instances.&#8221; That&#8217;s the bug. <code>copy()<\/code> is a second way in.<\/li>\n<li>&#8220;<code>copy()<\/code> clones the object, so it skips <code>init<\/code>.&#8221; It doesn&#8217;t. The data classes docs show <code>copy()<\/code> calling the primary constructor. The second run above shows <code>init<\/code> rejecting a bad copy.<\/li>\n<li>&#8220;Override <code>copy()<\/code> and make it private.&#8221; The data classes docs say explicit <code>copy()<\/code> implementations aren&#8217;t allowed. With 2.4.20, declaring one fails with &#8220;conflicting overloads.&#8221;<\/li>\n<li>&#8220;Add <code>@ExposedCopyVisibility<\/code> to silence it.&#8221; That hides the declaration warning and keeps the hole open. The call-site warning stays.<\/li>\n<li>&#8220;Validate in <code>init<\/code> and you&#8217;re done.&#8221; It only protects the rules you put there. The normalization rule in this example lived only in <code>of()<\/code>, so <code>\"Ana@Example.com\"<\/code> got through and broke equality.<\/li>\n<\/ul>","protected":false},"excerpt":{"rendered":"<p>A private constructor doesn&#8217;t stop the Kotlin data class copy() from creating invalid objects. The two causes, what Kotlin 2.5 changes and the fix.<\/p>\n","protected":false},"author":2,"featured_media":737,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Why a Private Constructor Doesn't Stop Kotlin Data Class copy()","rank_math_description":"A private constructor doesn't stop the Kotlin data class copy() from creating invalid objects. The two causes, what Kotlin 2.5 changes and the fix.","rank_math_focus_keyword":"data class copy","footnotes":""},"categories":[9,2],"tags":[15,34,65,16,12,45],"class_list":["post-735","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-bug-squash","category-kotlin","tag-android","tag-code-review","tag-data-class","tag-interview-prep","tag-kotlin","tag-technical-interview"],"_links":{"self":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/735","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/comments?post=735"}],"version-history":[{"count":1,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/735\/revisions"}],"predecessor-version":[{"id":736,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/735\/revisions\/736"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media\/737"}],"wp:attachment":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media?parent=735"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/categories?post=735"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/tags?post=735"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}