While true, a lot of this could be hidden behind a simple interface in a library.
scala> class NewType[A]:
| opaque type Type = A
| def apply(a: A): Type = a
| extension (a: Type) def value: A = a
| end NewType
// defined class NewType
scala> class NewIntType extends NewType[Int]:
| override opaque type Type = Int
| override def apply(a: Int): Type = a
| extension (a: Type) override def value: Int = a
| end NewIntType
// defined class NewIntType
scala> import scala.compiletime.erasedValue
scala> transparent inline def newType[A] = inline erasedValue[A] match {
| case _: Int => new NewIntType
| case _ => new NewType[A]
| }
def newType[A] => NewType[? >: Int & A <: Int | A]
scala> val Foo = newType[Int]; type Foo = Foo.Type
val Foo: NewIntType = NewIntType@346d8002
// defined alias type Foo = Foo.Type
scala> val Bar = newType[String]; type Bar = Bar.Type
val Bar: NewType[String] = NewType@593d5f39
// defined alias type Bar = Bar.Type
Notice you have one entrypoint to make a newtype “module”, newType[A]
, and when A
is an AnyVal
you statically get the specialized instance, otherwise the generic variant for types that don’t suffer from boxing.
Taking it even further would probably require something like macro annotations…