case-classes with better binary compatibility story
Scala
81
185 commits
updated Sep 10, 2026
data-class allows to create classes almost like case-classes, with no public
unapply method and with binary-compatible copy overloads, making it easier
to add fields while maintaining binary compatibility.
Add to your build.sbt,
libraryDependencies += "io.github.alexarchambault" %% "data-class" % "0.2.6"
The build uses Mill via the checked-in launcher. Run all tests with:
./mill dataClass.2_12_21.test.testForked
./mill dataClass.2_13_18.test.testForked
./mill test.compat.3_8_2.test.testForked
Check binary compatibility against applicable versions discovered from release tags with:
./mill __.mimaReportBinaryIssues
The macro paradise plugin is needed up to scala 2.12, and the right compiler option needs to be used from 2.13 onwards:
lazy val isAtLeastScala213 = Def.setting {
import Ordering.Implicits._
CrossVersion.partialVersion(scalaVersion.value).exists(_ >= (2, 13))
}
libraryDependencies ++= {
if (isAtLeastScala213.value) Nil
else Seq(compilerPlugin("org.scalamacros" % "paradise" % "2.1.1" cross CrossVersion.full))
}
scalacOptions ++= {
if (isAtLeastScala213.value) Seq("-Ymacro-annotations")
else Nil
}
Lastly, if you know what you are doing, you can manage to have data-class be a compile-time only dependency.
Sources shared by Scala 2 and Scala 3 can use a case class and rename
@since to @unroll:
import dataclass.{data, since => unroll}
@data case class Foo(a: Int, @unroll b: Boolean = false)
On Scala 2, @data expands the class and @since controls its compatible
constructor, apply, and copy overloads. On Scala 3, @data is a no-op and
@since is an alias for scala.annotation.unroll, so the case class and its
compatible overloads are generated by the compiler. Scala 3 currently requires
the -experimental compiler option to use @unroll.
Use a @data annotation instead of a case modifier, like
import dataclass.data
@data class Foo(n: Int, s: String)
This annotation adds a number of features, that can also be found in case classes:
equals / hashCode / toString implementations,apply methods in the companion object for easier creation,copy methods corresponding to the generated constructors,scala.Product trait (itself extending scala.Equal), and
implement its methods,scala.Serializable trait.It also adds things that differ from case classes:
final modifier to the class,with method (field count: Int
generates a method withCount(count: Int) returning a new instance of the
class with count updated).Most notably, it does not generate an unapply method. Its copy methods have
the same binary-compatible overloads as its constructors (see below). No copy
methods are generated if the class defines one itself.
With @data(settersCallApply = true), the generated with and copy methods create
new instances via the companion apply rather than via the constructor, which allows
apply to intern or validate instances for example (typically along with apply = false,
with a hand-written apply in the companion).
In the example above, the @data macro generates code like the following (modulo macro hygiene):
final class Foo(val n: Int, val s: String) extends Product with Serializable {
def copy(n: Int = this.n, s: String = this.s) = new Foo(n, s)
def withN(n: Int) = new Foo(n = n, s = s)
def withS(s: String) = new Foo(n = n, s = s)
override def toString: String = {
val b = new StringBuilder("Foo(")
b.append(String.valueOf(n))
b.append(", ")
b.append(String.valueOf(s))
b.append(")")
b.toString
}
override def canEqual(obj: Any): Boolean = obj != null && obj.isInstanceOf[Foo]
override def equals(obj: Any): Boolean = this.eq(obj.asInstanceOf[AnyRef]) || canEqual(obj) && {
val other = obj.asInstanceOf[Foo]
n == other.n && s == other.s
})
override def hashCode: Int = {
var code = 17 + "Foo".##
code = 37 * code + n.##
code = 37 * code + s.##
37 * code
}
private def tuple = (this.n, this.s)
override def productArity: Int = 2
override def productElement(n: Int): Any = n match {
case 0 => this.n
case 1 => this.s
case n => throw new IndexOutOfBoundsException(n.toString)
}
}
object Foo {
def apply(n: Int, s: String): Foo = new Foo(n, s)
}
With @data(deprecatedSetters = true), the generated with methods are annotated with
@deprecated, so that users of the class get warned when calling them (and are pushed
towards copy instead, say). The parameters of the generated @deprecated annotation
can be set with deprecatedSettersMessage and deprecatedSettersSince, like
@data(
deprecatedSetters = true,
deprecatedSettersMessage = "Use copy instead",
deprecatedSettersSince = "1.2"
)
class Foo(n: Int, s: String)
which generates setters like
@deprecated("Use copy instead", "1.2")
def withN(n: Int) = new Foo(n = n, s = s)
With @data(setters = false), no with method is generated at all, so that only
copy can be used to create updated instances. optionSetters and
deprecatedSetters have no effect then.
By default, the classes annotated with @data now have a shape that
shapeless.Generic handles:
import dataclass.data
@data class Foo(n: Int, d: Double)
import shapeless._
Generic[Foo] // works
Note that with shapeless 2.3.3 and prior versions, Generic derivation may fail
if the body of the @data class contains vals or lazy vals, see
shapeless issue #934.
In order to retain binary compatibility when adding fields, one should:
dataclass.since,import dataclass._
@data class Foo(n: Int, d: Double, @since s: String = "", b: Boolean = false)
The @since annotation makes the @data macro generate apply methods
compatible with those without the new fields.
The example above generates the following apply methods in the companion object of Foo:
object Foo {
def apply(n: Int, d: Double): Foo = new Foo(n, d, "", false)
def apply(n: Int, d: Double, s: String, b: Boolean) = new Foo(n, d, s, b)
}
The @since annotation accepts an optional string argument - a version
can be passed for example - and it can be used multiple times, like
import dataclass._
@data class Foo(
n: Int,
d: Double,
@since("1.1")
s: String = "",
b: Boolean = false,
@since("1.2")
count: Option[Int] = None,
info: Option[String] = None
)
This generates the following apply methods in the companion object of Foo:
object Foo {
def apply(n: Int, d: Double): Foo = new Foo(n, d, "", false, None, None)
def apply(n: Int, d: Double, s: String, b: Boolean) = new Foo(n, d, s, b, None, None)
def apply(n: Int, d: Double, s: String, b: Boolean, count: Option[Int], info: Option[String]) = new Foo(n, d, s, b, count, info)
}
@unroll support in Scala 3
should help evolving methods and classes a binary-compatible wayScala
80.4%
Batchfile
11.7%
Shell
7.9%
case-classes with better binary compatibility story
Scala
81
185 commits
updated Sep 10, 2026
data-class allows to create classes almost like case-classes, with no public
unapply method and with binary-compatible copy overloads, making it easier
to add fields while maintaining binary compatibility.
Add to your build.sbt,
libraryDependencies += "io.github.alexarchambault" %% "data-class" % "0.2.6"
The build uses Mill via the checked-in launcher. Run all tests with:
./mill dataClass.2_12_21.test.testForked
./mill dataClass.2_13_18.test.testForked
./mill test.compat.3_8_2.test.testForked
Check binary compatibility against applicable versions discovered from release tags with:
./mill __.mimaReportBinaryIssues
The macro paradise plugin is needed up to scala 2.12, and the right compiler option needs to be used from 2.13 onwards:
lazy val isAtLeastScala213 = Def.setting {
import Ordering.Implicits._
CrossVersion.partialVersion(scalaVersion.value).exists(_ >= (2, 13))
}
libraryDependencies ++= {
if (isAtLeastScala213.value) Nil
else Seq(compilerPlugin("org.scalamacros" % "paradise" % "2.1.1" cross CrossVersion.full))
}
scalacOptions ++= {
if (isAtLeastScala213.value) Seq("-Ymacro-annotations")
else Nil
}
Lastly, if you know what you are doing, you can manage to have data-class be a compile-time only dependency.
Sources shared by Scala 2 and Scala 3 can use a case class and rename
@since to @unroll:
import dataclass.{data, since => unroll}
@data case class Foo(a: Int, @unroll b: Boolean = false)
On Scala 2, @data expands the class and @since controls its compatible
constructor, apply, and copy overloads. On Scala 3, @data is a no-op and
@since is an alias for scala.annotation.unroll, so the case class and its
compatible overloads are generated by the compiler. Scala 3 currently requires
the -experimental compiler option to use @unroll.
Use a @data annotation instead of a case modifier, like
import dataclass.data
@data class Foo(n: Int, s: String)
This annotation adds a number of features, that can also be found in case classes:
equals / hashCode / toString implementations,apply methods in the companion object for easier creation,copy methods corresponding to the generated constructors,scala.Product trait (itself extending scala.Equal), and
implement its methods,scala.Serializable trait.It also adds things that differ from case classes:
final modifier to the class,with method (field count: Int
generates a method withCount(count: Int) returning a new instance of the
class with count updated).Most notably, it does not generate an unapply method. Its copy methods have
the same binary-compatible overloads as its constructors (see below). No copy
methods are generated if the class defines one itself.
With @data(settersCallApply = true), the generated with and copy methods create
new instances via the companion apply rather than via the constructor, which allows
apply to intern or validate instances for example (typically along with apply = false,
with a hand-written apply in the companion).
In the example above, the @data macro generates code like the following (modulo macro hygiene):
final class Foo(val n: Int, val s: String) extends Product with Serializable {
def copy(n: Int = this.n, s: String = this.s) = new Foo(n, s)
def withN(n: Int) = new Foo(n = n, s = s)
def withS(s: String) = new Foo(n = n, s = s)
override def toString: String = {
val b = new StringBuilder("Foo(")
b.append(String.valueOf(n))
b.append(", ")
b.append(String.valueOf(s))
b.append(")")
b.toString
}
override def canEqual(obj: Any): Boolean = obj != null && obj.isInstanceOf[Foo]
override def equals(obj: Any): Boolean = this.eq(obj.asInstanceOf[AnyRef]) || canEqual(obj) && {
val other = obj.asInstanceOf[Foo]
n == other.n && s == other.s
})
override def hashCode: Int = {
var code = 17 + "Foo".##
code = 37 * code + n.##
code = 37 * code + s.##
37 * code
}
private def tuple = (this.n, this.s)
override def productArity: Int = 2
override def productElement(n: Int): Any = n match {
case 0 => this.n
case 1 => this.s
case n => throw new IndexOutOfBoundsException(n.toString)
}
}
object Foo {
def apply(n: Int, s: String): Foo = new Foo(n, s)
}
With @data(deprecatedSetters = true), the generated with methods are annotated with
@deprecated, so that users of the class get warned when calling them (and are pushed
towards copy instead, say). The parameters of the generated @deprecated annotation
can be set with deprecatedSettersMessage and deprecatedSettersSince, like
@data(
deprecatedSetters = true,
deprecatedSettersMessage = "Use copy instead",
deprecatedSettersSince = "1.2"
)
class Foo(n: Int, s: String)
which generates setters like
@deprecated("Use copy instead", "1.2")
def withN(n: Int) = new Foo(n = n, s = s)
With @data(setters = false), no with method is generated at all, so that only
copy can be used to create updated instances. optionSetters and
deprecatedSetters have no effect then.
By default, the classes annotated with @data now have a shape that
shapeless.Generic handles:
import dataclass.data
@data class Foo(n: Int, d: Double)
import shapeless._
Generic[Foo] // works
Note that with shapeless 2.3.3 and prior versions, Generic derivation may fail
if the body of the @data class contains vals or lazy vals, see
shapeless issue #934.
In order to retain binary compatibility when adding fields, one should:
dataclass.since,import dataclass._
@data class Foo(n: Int, d: Double, @since s: String = "", b: Boolean = false)
The @since annotation makes the @data macro generate apply methods
compatible with those without the new fields.
The example above generates the following apply methods in the companion object of Foo:
object Foo {
def apply(n: Int, d: Double): Foo = new Foo(n, d, "", false)
def apply(n: Int, d: Double, s: String, b: Boolean) = new Foo(n, d, s, b)
}
The @since annotation accepts an optional string argument - a version
can be passed for example - and it can be used multiple times, like
import dataclass._
@data class Foo(
n: Int,
d: Double,
@since("1.1")
s: String = "",
b: Boolean = false,
@since("1.2")
count: Option[Int] = None,
info: Option[String] = None
)
This generates the following apply methods in the companion object of Foo:
object Foo {
def apply(n: Int, d: Double): Foo = new Foo(n, d, "", false, None, None)
def apply(n: Int, d: Double, s: String, b: Boolean) = new Foo(n, d, s, b, None, None)
def apply(n: Int, d: Double, s: String, b: Boolean, count: Option[Int], info: Option[String]) = new Foo(n, d, s, b, count, info)
}
@unroll support in Scala 3
should help evolving methods and classes a binary-compatible wayScala
80.4%
Batchfile
11.7%
Shell
7.9%