Synthesize constructor for opaque types

As I suggested on gitter, I think this is better to have them always be available, but optionally declared private like private constructors for classes, so it would be:

opaque type Name private = String
2 Likes

Agreed, automatic constructor / deconstructor in all case breaks the idea of newtype a bit :slight_smile:

  1. You can write def Name(x: String): Name = x, it’s not terribly long.
  2. An opaque type member is still just a type member, not a class. If we start adding more class-like features to it, where does it end? Eventually we’re just back to existing value classes with their shortcomings. If you want something that behaves like a class, use a class. If you’re uncomfortable with the allocation cost of a class (it’s really not a big deal most of the time), you’ll have to wait for proper value types on the JVM: https://openjdk.java.net/projects/valhalla/.
9 Likes

Agree with this. I guess people have the annotation fear from Java.

1 Like

I have strong opinions about your 2nd argument.

The whole use case for opaque types (IMHO) is to have a compiler enforced alias that doesn’t suffer the performance penalties of e.g. case classes.

The features I want are:

  1. No subtype relationship with the original or any other type
  2. Possibility to coerce typeclass instances from the underlying type on a per typeclass basis (see coercible trick even though it’s not recommended in general). This allows me to use my special opaque type Name = String in the context of e.g. doobie, circe, etc. without extra work.
  3. Ability to construct the opaque type outside of scope in order to use said type in e.g. interfaces.

I agree that case classes gives me most of these features, but that leaves little to no use cases in my own code for opaque types and none of the performance benefits.

2 Likes

There has been quite a long discussion about the syntax of opaques in the original proposal topic. I expressed strong opinions against the proposed syntax in favor of combination of these two syntax constructs:

// #1 for adding extension methods to no-runtime-overhead types
opaque Permission(i: Int) {
  def |(other: Permission): Permission = i | other.i
}
object Permission {
  val NoPermission =  Permission(0)
}

// #2 for defining type aliases that cannot be mixed in (`nominal` might not be the right keyword)
nominal type Password = String
nominal type GUID = String

I’d rather not repeat the discussion as I believe I’ve made my points clear in the previous one. Here’s the gist of it.

4 Likes

I ran into the supertagged library recently that uses an encoding for newtypes that is at least as good as opaque, and in some ways better (particularly when used alongside top-level type definitions).

It might be a useful thing to see if this encoding can be used to improve opaque's rough edges.

2 Likes

As I have mentioned before, the only real advantage of opaque types, which cannot be achieved with a library solution, is that they have the same erasure as their underlying type (so they won’t box primitives).

The rest of the opaque type design is a failure IMHO, as it still requires too much boilerplate, and does not integrate very well in the rest of the language. I think a class-like syntax would have been nicer.

5 Likes

I tend to agree - tbh. I care much more about boilerplate than performance in 99% of all cases.

1 Like

To discuss opaque types, it’s important to understand what they are. Opaque types are abstract types with a convenient way to define them. Here’s a typical example how to set up an abstract type.
For concreteness, I picked a functional queue abstraction.

class Elem
trait QueueSignature:
  type Queue
  def empty: Queue
  def append(q: Queue, e: Elem): Queue
  def pop(q: Queue): Option[(Elem, Queue)]
val QueueModule: QueueSignature =
  object QueueImpl extends QueueSignature:
    type Queue = (List[Elem], List[Elem])
    def empty = (Nil, Nil)
    def append(q: Queue, e: Elem): Queue = (q._1, e :: q._2)
    def pop(q: Queue): Option[(Elem, Queue)] = q match
      case (Nil, Nil) => None
      case (x :: xs, ys) => Some((x, (xs, ys)))
      case (Nil, ys) => pop((ys.reverse, Nil))
  QueueImpl

An abstract type such as Queue is a type member of some signature. Its concrete implementation is a type alias in a structure that implements that signature. I have picked the SML/OCaml terminology since that’s where this stuff comes from.

The idea of an abstract type is that it provides true encapsulation: You can interact with values of abstract types only by means of the functions that come with it. It’s a very powerful construct, but it’s also quite heavyweight. In particular the distinction between QueueSignature, QueueModule and QueueImpl can look like overkill if there’s only one implementation of the Queue type.

Opaque types optimize for this case. They give you exactly(*) the same properties as abstract types, but without the container boilerplate. Here is the definition of functional queues using an opaque type:

object queues:
  opaque type Queue = (List[Elem], List[Elem])
  def empty = (Nil, Nil)
  def append(q: Queue, e: Elem): Queue = (q._1, e :: q._2)
  def pop(q: Queue): Option[(Elem, Queue)] = q match
    case (Nil, Nil) => None
    case (x :: xs, ys) => Some((x, (xs, ys)))
    case (Nil, ys) => pop((ys.reverse, Nil))

As with abstract types, the important aspect of opaque types is that they naturally support true encapsulation: Everything one can do with an abstract type has to be explicitly defined with it.

Newtype in Haskell is different. It gives you a fresh type with conversions to and from another type. That just gives you a name, no encapsulation is achieved. You can achieve encapsulation by hiding the conversion functions but that requires additional effort. See Lexi-Lambdas excellent blog about this difference. https://lexi-lambda.github.io/

I think it’s best not to dilute the conceptual purity of the abstract type model with automatically generated conversions. If you need conversions, you should explicitly define them, just like any other function over an abstract type.

(*) Plus, they usually give you a more efficient implementation since the backend “knows” what the implementation type of an opaque type is.

20 Likes

Hmm. I’m kind of worried about this prioritization. This use case is not what I mainly want to use opaque for – I’d guess that 95% of my usage is going to be simply about wrapping an existing type with a thickly-walled more-specific type, replacing the current unreliable usage of AnyVal. (Heck, I’d guess that 50% of my usage is going to be nothing but providing strongly-typed versions of String.)

Automatic conversion is undesirable for my use case, but explicit conversion is 100% normal, and usually highly desirable. That really ought to be easy, as requested by the original poster.

So to put it simply: the “conceptual purity” here is off-base, IMO – the use case you are optimizing for is not the use case that has led many of us to advocate for opaque since Erik originally proposed it, lo these many months ago…

17 Likes

So an opaque type alias is basically just a type alias, except opaque.

I wrote an opaque type once, as a learning exercise, and contributed it, but I see it was changed to a class. It was encapsulating a StringBuilder, and the StringBuilder fell out of favor. Anyway, it served its advertised purpose.

There are one or two other opaque types in the compiler code base. I wonder if object opaques will join object implicits and object util in the pantheon of first names that sprang to mind. I tend to not remember the name but I can picture their face.

I can understand from the SIP how folks might feel abandoned at the end of the garden path, or Borges’s bifurcating paths. But I appreciate the power-to-weight ratio of the existing feature. Probably someone already requested if they couldn’t drop the opaque and make opacity the default. Then you could use export to expose its underlying structure. Also allow opaque type declarations.

To recap, I’d like export this.{foo => bar} for aliased “targetName” and export this.mytype to make my (opaque) type alias transparent to the world. I forgot to start with, “Dear Santa,…”

1 Like

This is pretty accurate.

1 Like

I’m afraid this will mean fewer people will actually use the feature. We could definitely implement the same pattern over and over, but in the end people will use String, Int, etc. directly just to avoid the boilerplate.
Alternatively, people would look to macros, but since there’s no annotation based macros here, I don’t see how it could even be implemented. This leaves us in a bad place for how we want to write our code.
I believe it’s also a matter of perspective: If you write “business logic” code, you are often dealing with Strings for IDs, names, etc. and you will end up wanting to make these opaque, whereas for library code you might have fewer and more select opaque types for your API.
Probably, application developers are less vocal about their needs wrt. language design :-/

9 Likes
opaque type def Name(s: String) = String(s)
opaque type def Name(s: String) = s   // as ascribed

And you get to pun typedef.

1 Like

For these wrapper types, do you protect how they are constructed, or are they completely public?

In by far the most cases they are only used for tagging. Things like tokens, IDs, hashes, etc. we often don’t validate. In the cases where we do, I don’t see any problem in creating the companion object by hand.
To be honest, I would be fine with using https://github.com/estatico/scala-newtype but it’s using annotation macros from Scala 2, so I guess it’s not compatible with Scala 3 (and can’t be implemented with the current macros solution).

To clarify, does this mean that they are not intended as a replacement for AnyVal wrappers? That’s how I’ve seen opaque types discussed, and from this explanation it sounds kind of like it’s sort of a bonus that they can be used to create a zero allocation wrapper (which would explain the relatively poor ergonomics for this use case).

To be honest, I’ve never seen anyone talking about using the type of encoding demoed by QueueSignature, is this just something I’ve somehow missed?

5 Likes

I totally see where @odersky is coming from. So yes by adding default constructors and read method, it will be tinting opaque types.

But it’s true that the majority of use case for opaque types will be for type-safe unbox alias to String, Long, etc.

So then, what is the solution ?

I think the solution proposed by @Jasper-M and @jdegoes is the best. By having case opaque type to generate the default constructor and read method, we can have the best of both.

3 Likes

If you feel the need for something like case opaque, then use a case class and be done with it. Classes are a well understood concept, and we do not need to invent a new thing that emulates them poorly and has surprising semantics: people are already surprised that opaque types aren’t opaque when pattern matching on them in a generic context, the more we make them more like class, the more they’re likely to get confused by the subtle differences between the two concepts.
The cost of allocating a class isn’t worth worrying about unless your allocation rate is extremely high or you have some drastic latency requirements. As a rule of thumb, I’d say that if you’re doing pure FP in Scala and are fine with the performance costs associated with that compared with doing everything in an imperative way, then you probably won’t notice the difference between using a class or an opaque type.

2 Likes