Proposal for Opaque Type Aliases

Nope, as mentioned before, outside of their definition they behave exactly like abstract type aliases do. You can already make opaques like this:

object Permission {
  type Permission
  def apply(i: Int): Permission = i.asInstanceOf[Permission]
  private[this] def unwrap(p: Permission) = p.asInstanceOf[Int]
  
  def (i: Permission) | (other: Permission): Permission = Permission(unwrap(i) | unwrap(other))
  def (i: Permission) isOneOf (permission: Permission): Boolean = (unwrap(i) & unwrap(permission)) != 0
}

And various tagging libraries like GitHub - estatico/scala-newtype: NewTypes for Scala with no runtime overhead let you do the same with less boilerplate.

3 Likes

More so, the one specific feature of opaque types have is that they’re “opaque” outside of their definition, but are transparent inside their containing class:

object outer {
  object inner {
    opaque type T = Int
    implicitly[T =:= Int] // found, T = Int here
  }
  implicitly[inner.T =:= Int] // NOT found! T != Int here
}

However, this is also not different from how typealiases behave – it’s just upcasting:

trait Opaque {
  type T
}

object outer {
  val inner: Opaque = new Opaque {
    override type T = Int
    implicitly[T =:= Int] // found, T = Int here
  }
  implicitly[inner.T =:= Int]  // NOT found! T != Int here
}

The type ascription on val inner: Opaque is what erases the knowledge of T=Int and forces inner.T to be treated like a unique unknown type – yet inside the definition of inner the knowledge is preserved. What we can’t do in Scala though is declare an object with an upcasted type, so short of stuffing lazy vals into a package object we can’t declare such upcasted opaques at top-level. Even without opaques dotty makes this pattern better by allowing to declare top-level vals – and perform upcasting, opaque types themselves just make this, rather obscure, pattern actually tolerable to execute be inventing special syntax for it.

4 Likes

This is very interesting, I haven’t though of that. What is then the purpose of the new feature if it’s already possible to have opaques? Either:

  1. This implementation is still not as efficient as could be? (which is the major motivation for the feature / design pattern).
  2. There’s too much boilerplate, in which case it seem to me that the new syntax is about the same.

Type members and type aliases may use the same keyword (type), but they are not the same; they behave differently and are used for different purposes.

Indeed type members seem more closely related to opaques, but the current implementation – much like the currently proposed feature – does not really acknowledge opaques as a thing of its own but rather as a design pattern achieved by combining other features (extension methods).

If we were to introduce a new syntax just for this design pattern, why not go all the way and include everything that the design pattern requires?

It’s also interesting to note how this library chose to encode “new type” in its newer version – via a wrapper case class with normal methods. The underlying implementation that uses type members and extension methods is invisible.

Replace “bytecode” with “runtime” and my point still stands.

As for whether semantics should have anything to do with it, they MUST. The entire reason for this proposal’s existence is due to a performance concern. Every use case can be handled today using a simple wrapper class, like class Logarithm(d: Double) { ... } – other than the performance concern from wrapping the value.

In order to ensure that this feature never requires boxing, and is purely a compiler side fiction, there must not be a runtime representation. This contrasts with extends AnyVal which has runtime representation, boxing, etc. One can not have an open ended discussion of semantics here: the requirements define the semantics. Syntax is of course always open to bike shedding, but semantics in this case are not.

An opaque type is not a class. It can’t be an ‘opaque class’ – classes by definition have distinct identity, and support runtime type inspection.

These are compiler-only types, with no runtime representation. There is an existing feature for such things.

Don’t think of them as aliases. Perhaps Opaque Type Alias is the wrong name. How about Opaque Type? I’m not sure what to name of the superset of Type Alias and Opaque Type – both are user provided types with no runtime representation.

I have never thought of them as aliases, just compiler-only types with differing visibility rules.

What I do find confusing syntactically is how one has to create a companion object for the type to define its member definitions, but its hard to argue with a simple extension of what exists today that mostly just tweaks visibility rules.

3 Likes

Again, it seems that opaques share similarities and differences with all of the other components (classes, type aliases and type members), but they are neither. They have their own unique semantics.

I believe that associating them with type would be confusing, as we can already see here the confusion between type aliases and type members, which are different but are mistaken as similar since they share the same keyword. Let’s not add yet another meaning into that soup.

As @kai shown, they could actually be achieved with Scala 2 (given that the performance is the same as what is proposed), so it’s only a matter of syntactic sugar. If that’s what this all about, I suggest making their syntax tailored specifically to their use case:

opaque Permission(i: Int) {
  def |(other: Permission): Permission = Permission(i | other)
  def isOneOf(permission: Permission): Boolean = (i & permission) != 0
}

object Permission {
  // provided by default and can be overridden with a different implementation
  def apply(i: Int): Permission = i
  // not provided by default
  def apply(name: String): Permission = ???

  val WriteOnly = Permission(2)
  val ReadOnly = Permission(4)
}

import Permission._

val readWrite = WriteOnly | ReadOnly // compiles
val readWrite: Int = WriteOnly | ReadOnly // doesn't compile

ReadOnly.isOneOf(readWrite) // compiles
ReadOnly.isOneOf(6) // doesn't compiles
ReadOnly.toDouble // doesn't compile

2 == WriteOnly // compiles and returns `true`
obj.isInstnaceOf[Permission] // doesn't compile
obj match { case p: Permission => ??? }  // doesn't compile

With sugar like that, and the other changes going into Dotty (‘new’ not needed when calling a constructor when not ambiguous, top level definitions), I think the companion object can be dropped.

An ‘opaque’ holds its methods in static scope, so holding values there is just more sugar:

opaque Permission extends Int {
  require(this >= 0)  // example of input validation
  def this(name: String) = ??? // alternate construction
  def |(other: Permission): Permission = Permission(this | other)
  def isOneOf(permission: Permission): Boolean = (this & permission) != 0

  // note, these values are static, just like the defs above.  These can not reference `this`
  val Write = Permission(2)
  val Read = Permission(4)
  val Execute = Permission(8)
}


import Permission._

val rw = Write | Read // compiles
val rw: Int = Write | Read // doesn't compile
val rwx = Write | Read | Execute

Read.isOneOf(rw) // compiles
Read.isOneOf(6) // doesn't compiles
Read.toDouble // doesn't compile

2 == Write // -- does not compile the author can add `def toInt: Int` or `def isInt(other: Int): Boolean`  if this is a real use case. Casting from an opaque to Any is not safe.
2 == (Write: Any) // compiles, returns true
obj.isInstnaceOf[Permission] // doesn't compile
obj match { case p: Permission => ??? }  // doesn't compile

I can’t disagree more. The entire point here is to introduce a new type that points to some underlying one. The keyword type is very precisely correct and clear…

5 Likes

I think that might be a bit confusing; how come a def has access to this but val doesn’t? I understand why that is but that seems like an irregularity comparing to classes and traits which share a similar syntactic structure.

I actually now think that it shouldn’t extend the wrapped type, as this gives the notion of a class, which it’s not. Also, the implementation there should be Permission(super | other); otherwise, this is endless recursive invocation.

Having the wrapped value as a “member” is better in multiple ways, I believe:

  1. No clear association with classes and inheritance.
  2. Actually represents the concept better - a type that wraps another type.
  3. Makes it clear that this type has only one immutable member (no other state).
  4. Reduces the need for this and super, as the wrapped type is a parameter with a name.

I guess I don’t have a strong opinion on that one. I’ll leave that to the discussion of multiversal equality.

It is very clear when you only consider this feature. When you consider the other features as well – type aliases and type members – this is becoming confusing, as they are all different things that behave differently and for different purposes.

Why not call everything a type? Why do we have classes and traits? Maybe we should just encode everything with zeros and ones, that is what this is all about after all.

This is yet another case of polysemy in the language (the other being implicit). Conflation of meanings, even if they share some similarities, is confusing and unproductive. We are not short of words, so why not use them?

An opaque type alias has the compile-time typing rules of an abstract type member, and the run-time semantics of a type alias.

Once you know that, everything is clear, and opaque type alias is a perfect name for this hybrid.

3 Likes

@sjrd has the precise definition, but to amplify that: the distinction between types and classes is central to Scala. It’s one of the topics I usually teach to folks the first day. They’re fundamentally different concepts.

This isn’t squishing everything into the word type – that word means something precise, specific and important, and it’s being used correctly here.

2 Likes

It might be clear for someone who’s working on the internals and is very proficient in Scala; it is not so clear for most developers. Just look a few comments above and see the confusion between type aliases and abstract type members.

And no, it doesn’t have the compile-time rules of an abstract type member. An abstract type member is just that – abstract – and only comes into play in conjunction with inheritance (anonymous or not). With opaques there is no inheritance; they are wrappers.

I don’t dispute that classes and types are different, but rather the other way around. My point is that we acknowledge the different between them and give them different words, and we should exactly the same for abstract type members, type aliases and opaques.

This attempt of linking these different concepts with the same keyword seem to me tantamount to trying and removing class from the language and replacing it with type. It wouldn’t be so hard to do as the two are mostly used with different constructs so there won’t be much (compiler) ambiguity. Obviously, that is a bad idea, and I pointed it out to try and illustrate why I think using type for opaques is a bad idea as well.

Anyhow, I don’t horribly mind it if the syntax looked like that:

opaque type Permission(i: Int) {
  def this(name: String) = ??? // alternate construction
  def |(other: Permission): Permission = Permission(this | other)
  def isOneOf(permission: Permission): Boolean = (this & permission) != 0
}

object Permission {
  def apply(yetAnotherConstructor: Double): Permission = ???
  val Write = Permission(2)
}

this could be replaced by something else. Having a fake constructor as in your example is confusing – the parameter can not be modified on input, it can not be public, val, var, etc… it shares nothing with an ordinary constructor list other than naming the variable.
this was the first thing that came to mind.

This construct shares more with object than traits or classes, so I am not concerned with objections about dissimilarity with trait or class semantics.

Yes, extends is not the best word. It was an existing keyword, I did not want to make a new one. I guess for or as may be more appropriate

opaque Permission as Int ?

No. this is of type Int not type Permission, so no recursion. That may be confusing, maybe a different name should be used, like inner or wrapped.

To be honest, I dislike the ‘fake member’ as much as I dislike ‘this’. I am not convinced whether a user supplied name is better than a fixed one either way. Both have advantages and disadvantages.

The scope is far larger than multiversal equality. See the discussion in this thread in the past. I would not want an Akka like send def send(Any: a) = ... to accept opaque types. Wrap it in a case class, or explicitly unwrap to int. Its not type safe to upcast these. I wouldn’t even want them to work in something like `s"{someOpaque}" unless that opaque explicitly has its own toString.
In short… I don’t think they are true subtypes of Any, because the language relies on runtime information for dispatch in many places (esp. pattern matching and OO polymorphism), and without runtime information they can not conform to the contracts of Any.

Thus, these values are not true subtypes of Any. You have to unpack one to get an Any value out, but can create a List[Permission] without doing so. Unfortunately, there is no escape from lack of safety, unless you forbid treating the type parameter in List[+A] covariantly if it is inhabited by an opaque type. There are probably other holes too, especially with union and intersection types. I don’t know what it would look like to try and patch them all or if its possible.

But, if the blatantly obvious passing of an opaque value to its ‘wrapped’ type is blocked, why not any other casting?

val x: Logarithm = ???
// why block this:
def square(d: Double) = d * d
square(x) // can't do it!
// what about this ?
def sneaky(d: AnyVal) = d match { case Double => d * d }
sneaky(d)
// or this?
def sneakyAny(d: Any) = d match { case Double => d * d }
sneakyAny(d)

I don’t understand why its not ok to supply a Logarithm for a Double parameter, but it is OK to substitute it for an Any parameter.

This is a compiler-only type, a user should have to unwrap or convert it before leaving where the compiler can track its type. IMO all the above should be invalid. Its not safe to implicitly erase an opaque type.

1 Like

I actually think the entire point is that they are not wrappers.

They are more like a disguise or costume. Hide the true runtme nature of the type from the compiler, and provide a different set of methods visible to the compiler.

5 Likes

I quite like that syntax.

I like the naming because there’s often a meaning to the value that’s being hidden, so allowing the programmer to give it a meaningful name is a win with almost no downsides from my perspective.

I like the scoped methods because it makes it easy to bring them into scope, and easy to see what does and does not have access to the hidden value.

I would suggest two changes to make it obvious this isn’t a class or object:

  1. Add a soft keyword hides to help hint that this isn’t a wrapper or a class, and that (i: Int) in the example isn’t a constructor parameter block.
  2. Refer to the hidden value by name, to reduce confusion about when you’re talking about opaque type alias, and when you’re talking about the value it hides. This should help avoid accidental recursion.
opaque type Permission hides (i: Int) {
  def this(name: String) = ??? // alternate construction
  def |(other: Permission): Permission = Permission(i | other.i)
  def isOneOf(permission: Permission): Boolean = (i & permission.i) != 0
  def lessThan(other: Permission): Boolean = i < other.i
}

object Permission {
  def apply(yetAnotherConstructor: Double): Permission = ???
  val Write = Permission(2)

  // Ideally, Permission would have an implicit scope, and this would be in it.
  // Apologies if the given syntax isn't up to date
  given as Comparator[Permission] = Comparator.fromLessThan(_ lessThan _)
}
1 Like

Fair enough, how about this:

opaque Permission { i: Int =>
}

I see that now, and I can’t say I’ve thought about it enough to make an opinion. My first observation though is that it’s unavoidable that opaques could be “upcasted” to Any, as type parameters act the same way:

trait Seq[A] {
  def head: A
  def isHeadInt: Boolean = head match {
    case i: Int => true
    case _ => false
  }
  def contains(elem: Any): Boolean = exists(_ == elem)
}

val permissions = Seq(Permission(0), Permission(2))
permissions.isHeadInt() // true
permissions.contains(0) // true
permissions.contains("lala") // false

And obviously there’s hashcode as well.

But they can, since all other types conform to the contract of Any, so they can rely on their wrapped types to fulfill that contract for them.

I don’t particularity like having a keyword followed by a parameters clause; they usually come only after an identifier — class name, function name, etc. How about this instead?

opaque Permission { i: Int =>
}

Just use the old syntax until we sort that thing out :slight_smile:

1 Like

Fair enough, and your proposal is close enough to self typing (and serves the same purpose), that it’s easy to reason about what it’s doing.

The question then becomes, how to we create these?

Is Permission.apply(Int) only created if there’s a synthetic companion object, and should it be created manually if a companion object is created by the programmer?

If Permission.apply(Int) isn’t created, what would it look like? It feels like it shouldn’t look like creating a class wrapper, as new Permission(4) looks way too much like what this isn’t. On the other hand, Permission(4) is a bit odd if you’re defining Permission.apply(Int) because you want to add some validation.

I’m guessing it could follow the rules of case classes. Is there any disadvantage to that? (it’s late and I can’t think clearly anymore)