Proposal for Opaque Type Aliases

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

It doesn’t fit the (intended) semantics of opaques. Opaques encapsulate their enclosed type by default, while these do not. Also, opaques are intended to be regularly extended (with additional methods that is), while these do not.

I don’t know how I would expect a bound alias to behave given that it’s impossible to express it in Scala 2. I see bound types as abstract definitions (of types), and type aliases as concrete ones. Combining them doesn’t make sense to me.

I’m not convinced that bound type aliases are describable. It seems like an overly complex feature that very few will understand and use regularly, perhaps a bit like forSome.

Then perhaps they deserve a different name. The shortest description I can think for them is “non interchangeable type aliases”, but obviously noninterchangeable is not a very good keyword.

Opaques hide the underlying type from the rest of the program. An “unbounded” opaque type has bounds >: Nothing <: Any, just like an unbounded type member does. You can narrow down the bounds but the underlying type stays hidden. Unless of course you do this:

scala> object Opaque { opaque type T >: String <: String = String }
// defined object Opaque

scala> implicitly[Opaque.T =:= String]
val res13: Opaque.T =:= String = generalized constraint

Perhaps the compiler could warn here that this doesn’t really make sense as an opaque type…

A type member can be a concrete alias in one context and a bounded abstract type in another. And the semantics are the same as those of an opaque type.

scala> trait Opaque {
     |   type T <: CharSequence
     |   def apply(s: String): T
     | }
// defined trait Opaque

scala> val Opaque: Opaque = new Opaque {
     |   type T = String
     |   def apply(s: String): T = s
     | }
val Opaque: Opaque = anon$1@297db6ad

scala> val t = Opaque("foo")
val t: Opaque.T = foo

scala> implicitly[t.type <:< String]
1 |implicitly[t.type <:< String]
  |                             ^
  |Cannot prove that (t : Opaque.T) <:< String

scala> implicitly[t.type <:< CharSequence]                                      
val res11: t.type <:< CharSequence = generalized constraint
1 Like

That’s exactly the difference in semantics between opaques and “non interchangeable type aliases”; the former do not hide the underlying type from the rest of the program.

Sure, that is the general idea of abstract definitions vs concrete ones. A definition may be either abstract, specifying a a contract that needs to be implemented in one of several possible ways; or concrete, specifying such an implementation (and potentially adhering to an abstract contract).

A definition cannot be both abstract and concrete; being concrete, it already has a specific implementation, so there is no meaning to add an abstract contract.

This is why this syntax is confusing:

opaque type Mag <: String = String

It contains both an abstract definition and a concrete one. How can a type be both concrete and abstract?

One more thing I realized with this example is that MagnificientString does not actually hide the underlying type:

MagnificientString("sky").toLowerCase() // compiles

This really is a bizarre case with strange semantics. I’m not sure it makes sense.

I think the confusion here is that an opaque type alias has a definition (the right-hand side of the =) which is hidden, but can also have bounds which aren’t hidden (the <: String bit). You’ve specified that even though outside of MagnificentString we can’t know that T is exactly String (the definition is hidden), we do know that it’s always a subtype of String (the bound isn’t hidden), and therefore you can use the toLowerCase() method. This isn’t a particularly real-world use case for opaque type aliases (though I can imagine some use case where having a transparent bound but an opaque definition might be useful)

3 Likes

I noticed that, yes. Perhaps this syntax would be more appropriate:

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

Still, this use case “punches new holes in the typespace” (@kai), as it allows subtyping a final type. It is somewhat equivalent to this:

// illegal obviously
class Mag(s: String) extends String(s)

(though the two use cases differ in that the class has a runtime presence and can override methods)

We really need to figure out if that use case is desirable, given that final types are explicitly intended not to be subtyped or inherited, and given the desire for classes to be final by default.

Edit:

I’m not sure whether the problem of this use case is with opaques or with the type bounds. How is it legal to express an upper type bound with a final?

final class A
type Sub <: A // what sense does this make?

This usecase is heavily desirable. case in point: shapeless.tag creates subtypes, not newtypes. There are other special tagging libraries focused on subtypes: refined, softwaremill/commons, scala-supertagged, squants, terminology. - I dare say this is the usecase that makes people turn to tagging - AnyVals do a reasonable job at newtypes already, going from an AnyVal to tagged type is just an optimization at the cost of obscurity and is generally not performed. Where you do see tagged types in real world code they’re usually subtypes, not newtypes. Without supporting subtyping opaque types would fail to do their job - transforming the widely used hacks-turned-patterns into an accessible and teachable idiomatic language feature.

2 Likes

This is nonsensical in Scala, every val is already a subtype:

final class X()

val x = new X()

implicitly[x.type <:< X] // is subtype of X
implicitly[x.type =:= X] // error: is NOT X

Subtyping in Scala is inescapable, final may mean something for inheritance, but for subtyping it means nothing. Having opaques accessible with surface-level syntax just makes that fact easier to learn for newcomers

5 Likes

@kai This is very interesting!

I wasn’t aware that the type of a class instance is not the type of the class itself. That reminds me more of dynamically typed languages rather than statically typed ones.

I’m not familiar with shapeless. I’ve read this article about shapeless-tags and their usage, and from it I gather that it may be the same case as TypeScript’s type branding? If so, TS seem address this use-case – along with “non interchangeable” type aliases – in this PR, which I believe doesn’t have the same semantics as opaques (it doesn’t hide the underlying type, I think).

I’m also wondering whether there are other solutions for the use cases that shapeless-tags address. Perhaps intersection types?

trait ProfileIdTag
type ProfileId = String with ProfileIdTag
object ProfileId {
  def apply(s: String): ProfileId = new String(s) with ProfileIdTag
  implicit def stringToProfileId(s: String): ProfileId = apply(s)
}

Actually, using intersection types I believe that we could achieve the same semantics as with the “magnificent strings” (but in Scala 2):

object MagExample {
  trait MagStringTag
  type MagString = String with MagStringTag
  object MagString {
    def apply(s: String): MagString = new String(s"magnificient-$s") with MagStringTag
  }

  val mags: List[MagString] = List("sky", "star", "snow").map(MagString.apply)
  val ordinaries: List[String] = mags

  def unmag(l: List[MagString]): List[String] = l.map(_.stripPrefix("magnificient-"))
  unmag(mags)
  unmag(ordinaries) // doesn't compile
}

I’m also wondering whether there are other solutions for the use cases that shapeless-tags address. Perhaps intersection types?

Shapeless tags are intersection types. (all subtype tagged type encodings are either intersection types or abstract upper-bounded types, or both)

That’s basically how type tagging works in Scala 2, except of course using .asInstanceOf, instead of new, because you can’t subclass final class String

1 Like

Right, IntelliJ didn’t bother marking that on static analysis.