Skip to content

Instantly share code, notes, and snippets.

@tpolecat

tpolecat/hmm.md Secret

Last active September 9, 2026 22:15
Show Gist options
  • Select an option

  • Save tpolecat/a5cb0dc9adeacc93f846835ed21c92d2 to your computer and use it in GitHub Desktop.

Select an option

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
Copy Markdown

Nice trick!

@MartinSnyder

Copy link
Copy Markdown

Sneaky!

@folone

folone commented Oct 18, 2016

Copy link
Copy Markdown

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

@ccmtaylor

Copy link
Copy Markdown

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
Copy Markdown

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
Copy Markdown
Author

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

@yawaramin

Copy link
Copy Markdown

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
Copy Markdown

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
Copy Markdown

@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

dk14 commented Nov 10, 2016

Copy link
Copy Markdown

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

greenrd commented Dec 7, 2016

Copy link
Copy Markdown

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
Copy Markdown

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

wjlow commented Jun 13, 2017

Copy link
Copy Markdown

@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
Copy Markdown

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

@jbgi

jbgi commented Aug 3, 2017

Copy link
Copy Markdown

@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
Copy Markdown

@jbgi @SethTisue or

private def copy() = ()

@matthughes

Copy link
Copy Markdown

@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

dwijnand commented Nov 5, 2018

Copy link
Copy Markdown

@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

longliveenduro commented Jul 21, 2020

Copy link
Copy Markdown

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