Skip to content

Instantly share code, notes, and snippets.

@tpolecat

tpolecat/hmm.md Secret

Last active February 1, 2024 11:46
Show Gist options
  • Save tpolecat/a5cb0dc9adeacc93f846835ed21c92d2 to your computer and use it in GitHub Desktop.
Save tpolecat/a5cb0dc9adeacc93f846835ed21c92d2 to your computer and use it in GitHub Desktop.

Case Classes w/ Smart Ctors

I just learned this trick from @OlegYch and it seems pretty good. Wondering if anyone knows of any problems with it.

People often try to constrain construction of a case class by making the constructor private and providing a partial factory method on the companion. For instance, a natural number class wrapping an Int:

case class Nat private (toInt: Int)
object Nat {
  def fromInt(n: Int): Option[Nat] =
    if (n >= 0) Some(Nat(n)) else None
}

We can construct instances with fromInt that behave as expected.

scala> Nat.fromInt(1)
res4: Option[Nat] = Some(Nat(1))

scala> Nat.fromInt(-1)
res5: Option[Nat] = None

scala> new Nat(42)
<console>:14: error: constructor Nat in class Nat cannot be accessed in object $iw
       new Nat(42)
       ^

However there are two holes:

scala> Nat.fromInt(42).get.copy(-67) // copy method is unsafe
res17: Nat = Nat(-67)

scala> Nat(-1) // oops, companion apply
res18: Nat = Nat(-1)

The traditional solution to this problem is to use a sealed trait or non-case class and hack up the rest of the machinery that makes it look like a case class, which is boilerplatey and irritating.


So, it turns out we can get around this by using the unlikely-sounding sealed abstract case class.

sealed abstract case class Nat(toInt: Int)
object Nat {
  def fromInt(n: Int): Option[Nat] =
    if (n >= 0) Some(new Nat(n) {}) else None
}

When we do this the behavior is what we want.

There's no companion apply.

scala> val n = Nat(10)
<console>:15: error: Nat.type does not take parameters
       val n = Nat(10)
                  ^

So we use the factory method.

scala> val on = Nat.fromInt(-1)
on: Option[Nat] = None

scala> val on = Nat.fromInt(10)
on: Option[Nat] = Some(Nat(10))

Pattern-matching and unapply work.

scala> val oi = on.collect { case Nat(n) => n }
oi: Option[Int] = Some(10)

scala> val n = on.get
n: Nat = Nat(10)

scala> Nat.unapply(n)
res11: Option[Int] = Some(10)

Our toInt field is public, and equals seems to do the right thing.

scala> n.toInt
res12: Int = 10

scala> Nat.fromInt(3) == Nat.fromInt(3)
res13: Boolean = true

And copy doesn't work.

scala> n.copy(-1)
<console>:18: error: value copy is not a member of Nat
       n.copy(-1)
         ^
@matthughes
Copy link

@yawaramin, @ccmtaylor, @mpilquist

Compiler only creates one anonymous class there; it's not per creation, it's per unique inner class.

object NatSealed {
  def apply(toInt: Int): NatSealed = new NatSealed(toInt) {}
}

object Main extends App {
  override def main(args: Array[String]): Unit = {
    println(NatSealed(1).getClass == NatSealed(2).getClass)
  }
}

@dwijnand
Copy link

dwijnand commented Nov 5, 2018

@jbgi

@SethTisue
this trick is still necessary with scala 2.12.2 because even with a private constructor, the copy method is still public :(
update: or alternatively also override the copy method (eg. to return an Option.)

Eliding the copy method entirely is being tracked (I think) by scala/bug#7884.

@longliveenduro
Copy link

longliveenduro commented Jul 21, 2020

The "sealed abstract case class" suggestion is not necessary any more:

https://gist.github.com/longliveenduro/62638ec95a6fd0c6576bdb77ca88cd6b

Tested in Scala 2.11.12 (with -Xsource:2.12), 2.12.10 and Scala 2.13.2:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment