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)
         ^
@dk14
Copy link

dk14 commented Nov 10, 2016

Example with private-package constructor:

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

scala> new Nat(-9){}
<console>:12: error: constructor Nat in class Nat cannot be accessed in <$anon: pack.Nat>
              new Nat(-9){}
                  ^

@greenrd
Copy link

greenrd commented Dec 7, 2016

This is not needed for the situations where you would use extends AnyVal - extends AnyVal makes a class wrapping an Int (say) into just an Int at runtime, so the equals, toString, hashcode etc. will work as you expect, and you don't want copy anyway.

@SethTisue
Copy link

As of Scala 2.11.11 (with -Xsource:2.12) and Scala 2.12.2, this trick is no longer necessary. Thanks to scala/scala#5730, you can make the constructor private with case class C private (...) and then put your smart apply method in the companion object.

@wjlow
Copy link

wjlow commented Jun 13, 2017

@SethTisue

Using Scala 2.12.2, you cannot seem to implement a smart apply method that returns an Option.

scala> case class Person private (name: String, age: Int)
defined class Person

scala> object Person {
     |   def apply(name: String, age: Int): Option[Person] =
     |     if (age < 0) None
     |     else Some(Person(name, age))
     | }
<console>:16: error: type mismatch;
 found   : Option[Person]
 required: Person
           else Some(Person(name, age))
                           ^

@michael-kendra
Copy link

@wjlow You have to write Some(new Person(name, age)) because you've made the default apply private.

@jbgi
Copy link

jbgi commented Aug 3, 2017

@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.)

@battermann
Copy link

@jbgi @SethTisue or

private def copy() = ()

@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