Proposal for Opaque Type Aliases

The only practical differences this proposal makes compared to what was already possible are:

  • a little syntax sugar (very little, to be fair)

  • a guarantee that the opaque types erase to the underlying type at runtime (if you tried to make an opaque over Int by tagging, it would erase to Object, but with the proposal it now erases to Int).

I was suggesting this as an additional feature which addresses the original motivation more directly, and was wondering why that solution wasn’t explored in the first place (or if it did I’m interesting to know why it failed).

I understand that there are other motivations for opaques that are still very relevant, which I believe are easier to be viewed as a better-performing wrapper that cannot be instance-checked at runtime.

I’m not exactly certain what you mean by that - are you referring to problems with the hypothetical “strict” type? If so, then I’d would be happy if you’d provide some concrete examples :slight_smile:

@Ichoran : (on the users forum)

Your suggestion doesn’t work because it assumes that all you want is for the new type to be propagated everywhere. But meters times meters are not meters, nor is Cents(i).leadingZeros a currency.

My suggestion (strict type) indeed doesn’t work for these use cases, as they aren’t the ones that were first mentioned in the SIP. These are not type aliases, which is what the original motivation is about – making the compiler help developers distinguish between different type aliases for the same underlying type.

It occurred to me how opaques – basically being a wrapper – are actually quite similar to delegators:

trait Artist {
  def name: String
  def create(): Art
}

class Painter(override val name: String) extends Artist {
  override def create(): Art = ???
}

opaque OpaqueConArtist { inspiration: Artist =>
  def name: String = s"${inspiration.name} the original"
}

delegator DelegatorConArtist { insporation: Artist =>
  def name: String = s"${inspiration.name} the original"
}

val bansky = new Painter("Bansky")
val opaqueArtist = OpaqueConArtist(bansky)
val delegatorArtist = DelegatorConArtist(bansky)

opaqueArtist.name == delegatorArtist.name

opaqueArtist.create() // doesn't compile
delegatorArtist.create() // == bansky.create()

def introduce(artist: Artist): String = s"${artist.name} belongs in a museum"
introduce(bansky)
introduce(opaqueArtist) // doesn't compile
introduce(delgetorArist)

1.isInstanceOf[OpaqueConArtist] // doesn't compile
1.isInstanceOf[DelegatorConArtist] // probably compiles

Opaques are not basically a wrapper. They are not a wrapper at all, in any sense. They are simply about not telling some code what something’s underlying type is. This is done by creating a plain old type alias with the twist that it lacks the transparency of the type aliases we’re used to.

2 Likes

I think we’ve gone over this discussion here and in here. The gist is that they are something unique (otherwise we wouldn’t be talking about them) that can be viewed in different ways:

  1. Compile-time wrappers; meaning, they should have better performance than plain wrappers, but on the other hand cannot be instance-checked (as it is a runtime capability).

  2. Abstract type members that are “instantiated” (aliased) only in their local scope (and most likely add functionality via extension methods).

Seeing how their motivation is mainly to implement value classes more efficiently than AnyVal, it’s easier for me to think of them as wrappers.

eyalroth

    February 17

I think we’ve gone over this discussion here and in here. The gist is that they are something unique (otherwise we wouldn’t be talking about them) that can be viewed in different ways:

  1. Compile-time wrappers; meaning, they should have better performance than plain wrappers, but on the other hand cannot be instance-checked (as it is a runtime capability).
  1. Abstract type members that are “instantiated” (aliased) only in their local scope (and most likely add functionality via extension methods).

Seeing how their motivation is mainly to implement value classes more efficiently than AnyVal, it’s easier for me to think of them as wrappers.

That’s like saying that if the motivation for function programming is to be safer than imperative programming, then functional programming is a kind of imperative programming.

Opaque types are not a way to implement value classes. They are a different solution than value classes.

Wrapper means you have something inside something else. In the context of classes that means a field inside a class. Value classes are that. You have a class that has a single field, so it’s wrapping it. And if it extends AnyVal then the compiler does some magic to make that extra layer of the wrapping class not get in the way as much possible, because normally by definition a wrapper is an extra layer and so gets in the way to some extent. That is why it has a performance impact. And the AnyVal approach is not so efficient because often there’s no way the compiler can make the wrapper be a fiction, and it has to remain a wrapper at runtime, with the resulting cost.

The whole entire point of opaque types is to skip the whole wrapper idea completely. We don’t pretend we are putting something inside something, so there is no outer something for the compiler to eliminate. Instead we just work with the values directly.

The only thing opaque types have in common with wrapper classes is that they serve a similar goal, which is “how do get this same value to have a low-level type in some scopes and a high-level type in some other scope.”

Value classes achieve this by wrapping the low-level value inside a class, which serves as the high-level type. This requires making no fundamental change to the type system, since the wrapper is supposedly a different value, and different values can always have different types. However generating the code to run is much trickier because we have to make one value act like it’s some other value that shouldn’t really exist.

Opaque types achieve the goal instead by modifying the type system with a new concept of being able to put boundaries on the validity of a type. Within one scope a value has one type, and within another scope the same value has a different type. This is a new feature of the type system. But the runtime code is identical to as with “transparent” type aliases (or no type alias at all).

3 Likes

I don’t think this analogue is accurate.

The motivation behind FP (or declarative programming) is probably complex; I’d guess it’s not about doing something better than imperative programming, but rather viewing fundamental concepts differently.

At the end of the day, FP produces (source) code that is vastly different than imperative, with different constructs and design patterns. Those differences are apparent on the surface (high) level, not only (or necessarily) on the implementation level.

In other words, when looking on opaques from the surface level, they seem to be just like wrappers. They serve the same goal. It is only when we dive into the implementation details that we can see the differences.

We could design opaques’ syntax to expose their low-level implementation – via type aliases and extension methods – or we could try and design them from a motivation / goal / usage oriented perspective, which is usually easier to grasp in my opinion.

The runtime code will be the same no matter what way we view opaques and how we design their syntax – it’s an implementation detail.

They aren’t, they don’t wrap anything. opaque means creating a new compile time only type that’s the same as the given type at runtime. I think you are reductionist when you are saying its an “implementation detail”, that is like saying that we should remove the difference between val/lazy val/def because they are “implementation details”.

If anything, AnyVal is something thats kind of a hack and there is some argument that it could be removed after Opaque types because the only thing that AnyVal does is making a wrapper class zero-cost in specific scenarios (not all!, i.e. if you pattern match on an AnyVal you automatically have to box to do a proper cast which removes any circumstances).

I guess you can say in summary, an opaque type is not a class where as an AnyVal is a class and the difference between a class and a type in Scala is fundamental, its not juts an “implementation detail”. Classes always box, they also have subclassing, inhertence, self types etc etc none of which even apply or make sense with opaque types**.

** Actually this is a bigger reason why AnyVal can be considered hacky is because it has manual compiler checks to prevent to certify these things

  • There is only one field
  • The fields are val
  • There is no inheritance
    Plus other things I might be missing, at which point doing case class Something(a: String) extends AnyVal makes less and less sense because its not a class or even a case class in any kind of sense of the word.

Also opaque types are kind of required to do sane interopt with other platforms (such as Scala.js) and was a primary motivator for Opaque types in the first place specifically because they aren’t wrappers.

1 Like

eyalroth

    February 17

nafg:
That’s like saying that if the motivation for function programming is to be safer than imperative programming, then functional programming is a kind of imperative programming.

Opaque types are not a way to implement value classes. They are a different solution than value classes.

I don’t think this analogue is accurate.

The motivation behind FP (or declarative programming) is probably complex; I’d guess it’s not about doing something better than imperative programming, but rather viewing fundamental concepts differently.

Replace “safer” with “X,” it makes no difference.

The point is that “serves the same goal” does not imply “is kind of the same thing.”

In other words, when looking on opaques from the surface level, they seem to be just like wrappers. They serve the same goal.

Again, one has nothing to do with the other.

I would assume “look at [them] from the surface level” would mean “judge them by their syntax,” which is does not look like a wrapper at all.

It is only when we dive into the implementation details that we can see the differences. We could design opaques’ syntax to expose their low-level implementation – via type aliases and extension methods – or we could try and design them from a motivation / goal / usage oriented perspective, which is usually easier to grasp in my opinion.

I don’t know what “usage-oriented” means but it’s not the same as “motivation / goal.”

In any case, there are 3 aspects of a feature that have been mentioned, “motivation / goal,” syntax, and implementation details. But there’s another aspect, which is the most important: the “idea / concept / paradigm.” You’re right that the syntax should not be designed around implementation details, but the goal (in this case, “how do get this same value to have a low-level type in some scopes and a high-level type in some other scope”) doesn’t either dictate syntax on its own. The goal is just a problem statement. The thing that guides both syntax and implementation to successfully achieve a goal is the concept or paradigm. In this case, we have one goal, with two different concepts or approaches (“wrap it in something else” vs. “hide its original type”), and the syntax and implementation flow naturally from each.

Wrappers are actually called “adapters”. The former name is derived from their implementation, while the latter (the official name) is derived from their purpose – that is, “to allow an interface of an existing class to be used as another interface” – which is the same purpose that opaques serve; i.e, adapting a type to another one.

Then call opaques “adapters for types” and wrappers “adapters for classes”. I believe it’s much more important to recognize this similarity and to emphasize that opaques are used as adapters, rather than focusing on the implementation details which give no hint on the intended usage.

My entire point is that the current syntax is a bit misguided.

// I'd rather have this
// (which resembles the syntax of wrappers)
opaque Permission(x: Int) {
  def |(other: Permission): Permission = Permission(x | other.x)
}

// than this
object Access {
  opaque type Permission = Int
  implicit class Ops(x: Permission) {
    def |(other: Permission): Permission = x | other // is that recursive or what?
  }
}

This is not the goal, but rather the solution to the goal – to have value classes; i.e, adapters without the performance penalty of wrappers. The syntax should not flow from the solution / implementation detail.

Why would I call an opaque type an adapter? It’s not a thing that adapts something, as a wrapper is. Indeed as you said wrappers are often adapters and adapters are (almost?) always a wrapper. Anyway IIUC many AnyVals are not adapters, since adapter means you’re trying to bridge two existing types, while often the author of the wrapper class is creating the wrapper class as it’s own new type.

But opaque types are neither, because by definition you’re creating a new type. So you’re not adapting anything to anything.

In fact, they’re the exact opposite. Adapter means you’re trying to bridge two types – you’re solving the problem of two types not being the same. In contrast, opaque types is where you’re trying to create a gap between two types, solving the problem of a value being an unwanted type.

In other words, with adapters some code needs a type that my value does NOT conform to and I want to be ABLE to use it, and with opaque types some code wants a type that my value DOES conform to and I want the user to NOT be able to use it.

3 Likes

I think we’re going around in circles with definitions. Let’s try something different.

Putting performance considerations aside, give me one example where opaques would be used in a way that wrappers cannot.

To provide safer facades to platform APIs (for JS APIs see https://www.scala-js.org/doc/interoperability/facade-types.html, something similar should apply to native): if you want to restrict the values that can be passed to a particular JS function, you can’t use a wrapper because the erased type signature of the facade method has to match the type signature of the JS function, but you could use an opaque type.

2 Likes

I’m not very fluent in JavaScript, TypeScript or Scala.js. There is nothing in this documentation that explains what are “facade types”; it is only said they are similar in spirit to TS type definitions (and then a link to TS documentation which doesn’t discuss type definitions).

What I can tell is that this documentation is about interoperability of Scala.js with JS libraries, which seems to suffer from a lot of irregularities and have different semantics than JVM Scala.

Would it be wise to design this new feature based on a use case relevant only to a different platform that already has so many irregularities?


On another note, the mentioning of JS and TS got me searching.

I found out that JavaScript already has opaque types via Flow (static type checker tool), and they are described similarly to this proposal.

On the other hand, TypeScript sees the topic differently. There is a long awaited feature for “nominal typing”; i.e, distinguishing between type aliases of the same underlying type without encapsulation of the underlying type’s methods. There are ways to emulate this feature though, so it’s unclear when (or if) it will be implemented, but it seems that opaques are out of the picture.

It seems that “nominal types” are the same as my suggested “strict types” from earlier, which address the problem more directly. It yet again begs the question of what is the purpose of opaque types, and what can they provide that wrappers cannot, except for better performance.

Even if it were true that there would be no JVM example of a case where opaque types can be used and wrappers cannot, and the JVM were the only platform, that does not mean there is no justification for opaque types. You asked for an example of one thing and then you inferred something else from that.

I guess there are 3 types of motivations for a new feature (that have come into the discussion) that helps with a goal that can already be achieved. (1) If it’s more performant, (2) if there are cases that cannot be done the previous way, and (3) if the new way produces more understandable code. I guess you already eliminated (1) and (3) so you need a case of (2) but it should be for the JVM?

2 Likes

Whether it has to do with JS or JVM is irrelevant. opaque types allow you to specify that some type is the exact same as the other type on the underlying platform while allowing restrictions on the constructor, i.e. if you want to make a PositiveInt type on Scala.js you can use an opaque type to do this, i.e.

object JsTypes {
  opaque type PositiveInt = scalajs.types.Double //This isn't the actual Scala.js double type, using it for illustration
  object PositiveInt {
    def fromDouble(value: scalajs.types.Double) = ???
    def toDouble: scalajs.types.Double = ???   
  }
}

Since opaque types strictly are not a wrapper/adaptor and rather they just form a new type at runtime, you can guarantee that at runtime (i.e. in your compiled Javascript code) all instances of PositiveInt will actually appear as a Javascript Number type with zero performance cost.

We also have the same irregularities even on the JVM, its just that most people don’t care or we just type alias the Java implementation (with all of its problems). If for example you are writing performance sensitive code and you need a fast Option type, you basically have to manually implement opaque types in current Scala with its workarounds (currently right now Option always boxes), see https://github.com/sjrd/scala-unboxed-option as an example. You can go even crazier and make another Option type that is represented as value in some | null as an opaque type.

In these cases you cannot represent it as an AnyVal because it isn’t an AnyVal. Its not wrapping anything and you deliberately don’t want it to wrap anything.

Opaque types are also really handy for Scala native for the same reasons posted as above.

1 Like

I am not disputing the need for opaques nor do I ask for a better justification. I am times and times again trying to view them differently only so their syntax will be clearer (in my opinion, obviously).

I am by no means eliminated motivation #1 (performance), but argue that it shouldn’t affect syntax.

Yes, I am wondering what cases qualify as motivation #2. As of the Scala.js example, I didn’t infer it at all; quite the opposite, I expressed unfamiliarity with the topic and hoped for a better and more specific explanation. I indeed noted that if this example is platform-specific, then I’d question its significance in the overall design of opaques’ syntax.

That leaves us with motivation #3, which is what I’m trying to focus on – more readable code.

I am not disputing their usefulness, but rather their syntax / encoding.


Let’s get more concrete and compare between the syntax alternatives.

Opaque as a type modifier

This is the currently proposed syntax:

object Access {
  opaque type Permission = Int
  implicit class ops(p: Permission) {
    def |(other: Permission): Permission = p | other
  }
  val NoPermission: Permission = 0
}

Cons:

  1. Enclosing object with a different name is required.
  2. Need to be familiar with extensions in order to add functions.
  3. Ambiguity in method invocation (is p | other recursive?).
  4. Need to be familiar with additional scope rules / semantics.

Opaque as a type entity

This is an alternative syntax in which opaques are type entities – much like class and object are. It doesn’t mean they are classes, but that they are on-par with classes in terms of syntax.

opaque Permission(i: Int) {
  def |(other: Permission): Permission = i | other.i
}
object Permission {
  val NoPermission =  Permission(0)
}

I believe this syntax overcomes all of the cons of the previous syntax. It feels familiar as it uses the “entity” syntax where an encapsulated data type is associated with methods. It can also leverage existing access modifiers (private) to control the level of encapsulation; it doesn’t need new scope mechanisms.

Nominal types

This syntax is not an alternative to the previous two, but a complementary one. It better addresses the need to distinguish between type aliases of the same underlying type without encapsulating them.

nominal type Password = String
nominal type GUID = String

These are not intended to be extended with more functions – that’s what opaques are for. Indeed the semantics of those need to be further worked on:

import Password

val password: Password = "god123"

password + password // ok
password.toLowerCase() // what's the return type?
password + "x" // allowed? what's the return type?
2 Likes

I agree that

   implicit class ops(p: Permission) {
    def |(other: Permission): Permission = p | other
  }

seems confusing, and naively looks like a recursive function that will never terminate.

1 Like

Putting performance considerations aside, give me one example where opaques would be used in a way that wrappers cannot.

Opaques can have lower and upper bounds. They metaphorically punch new holes in typespace where there were no types before, they can add arbitrary subtypes or supertypes to any, even final, classes and participate in variance in a way that wrappers cannot. e.g.

type MagnificientString = MagnificientString.T
object MagnificientString {
  opaque type T <: String = String
  def apply(s: String): T = s"magnificient-$s"
}

val mags: List[MagnificientString] = List("sky", "star", "snow").map(MagnificientString(_))
// list mags is list string
val ordinaries: List[String] = mags

def unmag(l: List[MagnificientString]): List[String] = l.map(_.stripPrefix("magnificient-"))
unmag(mags)
// the opposite is not the case
unmag(ordinaries) // Found: (ordinaries : List[String]) Required: List[MagnificientString]
4 Likes