Proposal for Opaque Type Aliases

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.

4 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 Write facade types for JavaScript APIs - Scala.js, 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.

3 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 GitHub - sjrd/scala-unboxed-option: A type-parametric unboxed Option type for Scala 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

This is a great example, thank you. I’m not sure whether this is something we want, but it’s definitely a new possibility. Regardless, wouldn’t you agree that this encoding is preferable?

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

Well, no, it implies both a direct constructor (might not want it) and direct destructor (same) and you can’t exactly name write it like:

opaque Mag[S <: String] private (private val s: S)

Because, well, s is not a val. And the parens are not a constructor either. And the overall structure doesn’t actually tell that:

  1. Mag is actually parameterless, you declare it as Mag[S <: String], but refer to it as just Mag (val mags: List[Mag]). This is counter-intuitive
  2. Mag itself conforms to <: String, not its parameter. It should be Mag extends String at least.

Overall as a way to write the above, this is very counter-intuitive.

On the other hand, having to ‘re-export’ opaques as in

type Mag = Mag.T
object Mag {
  opaque type T <: String = String
}

Is odd, opaque, non self-explaining, entirely unobvious to anyone and laborious boilerplate, I’d rather have the decision to delete companions for opaques be reverted so that more obvious syntax can be used.

2 Likes

How about something like this:

opaque MagnificientString[T <: String] = T {
  def unwrap: T = this
}
object MagnificientString {
  def apply(s: String): MagnificientString = MagnificientString(s"magnificient-$s")
}

This avoids implying construction, or the existence of a member which doesn’t actually exist.

If the intent is that the compiler treat them as different for things like implicit lookup as well, would newtype encapsulate this idea?

But that’s the syntax with classes – and it works. As for private val that’s not necessary because the syntax is equivalent to that of class (members are private by default) and not case class. That also means no “destructor” (I assume you mean unapply).

This is a fair point.

Why? we can think of opaque as being the type of its “member”. If the member is S, and S is subtype of String, then the opaque is a subtype of String.

I agree, and I’m glad we see things the same way. I think this use-case is quite new and unexplored, and perhaps deserves its own unique syntax. Or maybe it can be achieved with the nominal type syntax:

nominal type Mag <: String = String
// or maybe even just
nominal type Mag = String

We need to explore nominal types semantics more for that, though.

I’m not sure the implied constructor is a problem:

val mag: Mag = "hey"
// but that is also ok:
val mag: Mag = Mag("hey")
// or
val mag = Mag("hey")

I do however think that the = is irregular and confusing in this location, as it doesn’t exist for other “entities”.

And there’s still the problem of the square brackets, which imply that the opaque should be declared Mag[_], but in fact it’s Mag.

Maybe something like this?

opaque Mag(s: Mag.S) { ... }
object Mag {
  type S <: String
}

Though I don’t like abstract type syntax inside objects in the first place (discussed earlier).

Maybe use trait inheritance?

trait MagT {
  type S <: String
}
opqaue Mag(s: Mag.S) extends MagT {
  type S = String
}
// or
trait MagT[S <: String] {
  def s: S
}
opqaue Mag(s: String) extends MagT[String] {

Though both of these look bizzare.

I am not sure what newtype is, and I haven’t thought about nominal types and their interaction with implicit lookup; again, I believe their semantics need to be further explored.

Wasn’t new type Foo = String one of the alternative proposals for the syntax in the original SIP

It would be weird to create separate syntax for it, because it fits perfectly with the intended semantics of opaque types. And it fits perfectly with how you expect a bounded type alias to behave.

Moreover I don’t think a special “nominal types” feature makes sense in Scala, because Scala already has nominal types (classes, traits …). It makes more sense for typescript which has a purely structural type system.

1 Like