To discuss opaque types, it’s important to understand what they are. Opaque types are abstract types with a convenient way to define them. Here’s a typical example how to set up an abstract type.
For concreteness, I picked a functional queue abstraction.
class Elem
trait QueueSignature:
type Queue
def empty: Queue
def append(q: Queue, e: Elem): Queue
def pop(q: Queue): Option[(Elem, Queue)]
val QueueModule: QueueSignature =
object QueueImpl extends QueueSignature:
type Queue = (List[Elem], List[Elem])
def empty = (Nil, Nil)
def append(q: Queue, e: Elem): Queue = (q._1, e :: q._2)
def pop(q: Queue): Option[(Elem, Queue)] = q match
case (Nil, Nil) => None
case (x :: xs, ys) => Some((x, (xs, ys)))
case (Nil, ys) => pop((ys.reverse, Nil))
QueueImpl
An abstract type such as Queue
is a type member of some signature. Its concrete implementation is a type alias in a structure that implements that signature. I have picked the SML/OCaml terminology since that’s where this stuff comes from.
The idea of an abstract type is that it provides true encapsulation: You can interact with values of abstract types only by means of the functions that come with it. It’s a very powerful construct, but it’s also quite heavyweight. In particular the distinction between QueueSignature
, QueueModule
and QueueImpl
can look like overkill if there’s only one implementation of the Queue
type.
Opaque types optimize for this case. They give you exactly(*) the same properties as abstract types, but without the container boilerplate. Here is the definition of functional queues using an opaque type:
object queues:
opaque type Queue = (List[Elem], List[Elem])
def empty = (Nil, Nil)
def append(q: Queue, e: Elem): Queue = (q._1, e :: q._2)
def pop(q: Queue): Option[(Elem, Queue)] = q match
case (Nil, Nil) => None
case (x :: xs, ys) => Some((x, (xs, ys)))
case (Nil, ys) => pop((ys.reverse, Nil))
As with abstract types, the important aspect of opaque types is that they naturally support true encapsulation: Everything one can do with an abstract type has to be explicitly defined with it.
Newtype in Haskell is different. It gives you a fresh type with conversions to and from another type. That just gives you a name, no encapsulation is achieved. You can achieve encapsulation by hiding the conversion functions but that requires additional effort. See Lexi-Lambdas excellent blog about this difference. https://lexi-lambda.github.io/
I think it’s best not to dilute the conceptual purity of the abstract type model with automatically generated conversions. If you need conversions, you should explicitly define them, just like any other function over an abstract type.
(*) Plus, they usually give you a more efficient implementation since the backend “knows” what the implementation type of an opaque type is.