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

Nice trick!

@MartinSnyder
Copy link

Sneaky!

@folone
Copy link

folone commented Oct 18, 2016

Still needs a private constructor though, otherwise new Nat(-13){} would work.

@ccmtaylor
Copy link

Won't new Nat(10) {} create a new inner class on every invocation? I'd worry that this could lead to similar issues as java's double brace initialisation "idiom". This article goes into it in more detail https://blog.jooq.org/2014/12/08/dont-be-clever-the-double-curly-braces-anti-pattern/

@ccmtaylor
Copy link

It's probably not to bad, because since the only invocations are in the companion, that should be the only context that can leak, and the companion object instance will always be in memory anyway.

@tpolecat
Copy link
Author

@folone only in the same source file since it's sealed.

@yawaramin
Copy link

Great trick! I've been confuzzled by the case class hole before, this will be awesome for that.

@ccmtaylor you can define a private implementor class and return instances of that (upcast to Nat) to keep class count down.

@malcolmgreaves
Copy link

Wonderful! Thank you for sharing. This solution provides a really nice, elegant way to handle creating values with restricted ranges.

I can imagine using this idea with extends AnyVal for lightweight type "wrappers" around primitives with range restrictions.

@yawaramin
Copy link

@malcolmgreaves just tried it. Not doable, it seems--looks like extends AnyVal automatically makes the case class final, which means we can't make it abstract.

@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