Proposal for Opaque Type Aliases

flatMap being both an intrinsic part of a data structure and a generic behavior it happens to exhibit are not mutually exclusive - just ask a for-comprehension :wink:

If you know you’ve got an EitherT, it’s helpful to be able to automatically widen the error type. That doesn’t mean you shouldn’t be able to use it with a generic function which only needs to know how to flatMap over it.

Perhaps, but this design still smells for me. I would even consider naming the methods differently.

Note that if implicit conflict resolution was available, then this wouldn’t have been a problem:

lens FlatMap {
  implicit class FlatMapOps[F: FlatMap](a: F) {
    def flatMap = ???
  }
}

lens EitherT includes FlatMap {
  implicit class EitherTOps[F[_], A, B](a: EitherT[A, B]) {
    def flatMap = ???
  }
  resolve EitherT.flapMap with EitherTOps // not perfect syntax obviously
}

Cats does this in places (fmap for the map provided by Functor comes to mind), but you’re going to run into naming collisions eventually.

One place I’ve found this technique of an instance which is a forwarder to a method particularly useful is for abstracting over folds - it doesn’t make sense to write one helper for the collections library, and another for the cats data types like NonEmptyList, but as cats has instances which are basically forwarders to the collections methods, I can write my helpers targeting Foldable and cover both cases.

Naming them different things would also break for comprehensions.

1 Like

How so? Shouldn’t one of the implementations always be available?

I’m going over the original SIP again, and I’m wondering whether the original motivation can be satisfied with a simpler solution.

The SIP explains that the original motivation is to be able to make the compiler help differentiate between different type aliases of the same type – for instance, Id and Password, both are String. One solution to this are wrapper case classes, but since they may incur a performance penalty, a new solution was needed.

Opaques seem to me mostly of a better-performing emulation of wrapper case classes; but how about designing a solution that is tailored to the original motivation – differentiating between type aliases?

object aliases {
  // good old aliases
  type Id = String
  type Password = String
  // new "strict" aliases
  strict type StrictId = String
  strict type StrictPassword = String
}

val id: Id = "a"
val strictId: StrictId = StrictId("b")

// compiles
val s: String = id
val pwd: Password = id
val strictId2: StrictId = strictId
id.toLowerCase()
strictId.toLowerCase() // this is different than `opaque` behavior

// doesn't compile
val s2: String = strictId
val id2: Id = strictId
val strictPwd: StrictPassword = "x"
val strictPwd: StrictPassword = id
val strictPwd: StrictPassword = strictId

// potentially compiles?
val s2: String = strictId.asInstanceOf[String]
val id2: Id = strictId.asInstanceOf[Id]

I don’t have any strong preference to the strict keyword – it’s just the first thing that popped into mind – but I do believe that opaque is inadequate for such a feature; in fact, I think it could be useful to introduce both features, where’s opaque really serves more of a lightweight wrapper, and uses the more class-like syntax accordingly.

It doesn’t make sense to me that you would be able to transparently call a method on a strict receiver, but not pass it transparently as a parameter.

1 Like

Why? As far as I understand that is what was expected by developers who used type aliases but got it wrong, as type aliases can be mixed like that. Why not have an ability to make aliases behave like aliases in all manners except assignability?

Practically: you lose the ability to hide members.

Consider

opaque type IArray[+T] = Array[T]

That can’t work if I can call update on an IArray. I would need to fall back to a value class.

Also, you now have a situation where in a selection a.b, a’s type isn’t a subclass of b’s owner. I don’t know if that makes a theoretical difference but it’s really quite strange and as far as I know, unprecedented in any OO language.

I believe right now Dotty supports opaque type A <: B = C, where C <:< B, so you know A <:< B but Aand C are unrelated, which might give you some of what you want from your proposal.

1 Like

How does the above help if I don’t want to expose the underlying type? That stuff above honestly just look like a tagged type, which we can already do today with shapeless.

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.