Scala 3 — Book

Opaque Types

Language
This doc page is specific to Scala 3, and may cover new concepts not available in Scala 2. Unless otherwise stated, all the code examples in this page assume you are using Scala 3.

Opaque type aliases provide type abstraction without any overhead. In Scala 2, a similar result could be achieved with value classes.

Abstraction Overhead

Let us assume we want to define a module that offers arithmetic on numbers, which are represented by their logarithm. This can be useful to improve precision when the numerical values involved tend to be very large, or close to zero.

Since it is important to distinguish “regular” double values from numbers stored as their logarithm, we introduce a class Logarithm:

class Logarithm(protected val underlying: Double):
  def toDouble: Double = math.exp(underlying)
  def + (that: Logarithm): Logarithm =
    // here we use the apply method on the companion
    Logarithm(this.toDouble + that.toDouble)
  def * (that: Logarithm): Logarithm =
    new Logarithm(this.underlying + that.underlying)

object Logarithm:
  def apply(d: Double): Logarithm = new Logarithm(math.log(d))

The apply method on the companion object lets us create values of type Logarithm which we can use as follows:

val l2 = Logarithm(2.0)
val l3 = Logarithm(3.0)
println((l2 * l3).toDouble) // prints 6.0
println((l2 + l3).toDouble) // prints 4.999...

While the class Logarithm offers a nice abstraction for Double values that are stored in this particular logarithmic form, it imposes severe performance overhead: For every single mathematical operation, we need to extract the underlying value and then wrap it again in a new instance of Logarithm.

Module Abstractions

Let us consider another approach to implement the same library. This time instead of defining Logarithm as a class, we define it using a type alias. First, we define an abstract interface of our module:

trait Logarithms:

  type Logarithm

  // operations on Logarithm
  def add(x: Logarithm, y: Logarithm): Logarithm
  def mul(x: Logarithm, y: Logarithm): Logarithm

  // functions to convert between Double and Logarithm
  def make(d: Double): Logarithm
  def extract(x: Logarithm): Double

  // extension methods to use `add` and `mul` as "methods" on Logarithm
  extension (x: Logarithm)
    def toDouble: Double = extract(x)
    def + (y: Logarithm): Logarithm = add(x, y)
    def * (y: Logarithm): Logarithm = mul(x, y)

Now, let us implement this abstract interface by saying type Logarithm is equal to Double:

object LogarithmsImpl extends Logarithms:

  type Logarithm = Double

  // operations on Logarithm
  def add(x: Logarithm, y: Logarithm): Logarithm = make(x.toDouble + y.toDouble)
  def mul(x: Logarithm, y: Logarithm): Logarithm = x + y

  // functions to convert between Double and Logarithm
  def make(d: Double): Logarithm = math.log(d)
  def extract(x: Logarithm): Double = math.exp(x)

Within the implementation of LogarithmsImpl, the equation Logarithm = Double allows us to implement the various methods.

Leaky Abstractions

However, this abstraction is slightly leaky. We have to make sure to only ever program against the abstract interface Logarithms and never directly use LogarithmsImpl. Directly using LogarithmsImpl would make the equality Logarithm = Double visible for the user, who might accidentally use a Double where a logarithmic double is expected. For example:

import LogarithmsImpl.*
val l: Logarithm = make(1.0)
val d: Double = l // type checks AND leaks the equality!

Having to separate the module into an abstract interface and implementation can be useful, but is also a lot of effort, just to hide the implementation detail of Logarithm. Programming against the abstract module Logarithms can be very tedious and often requires the use of advanced features like path-dependent types, as in the following example:

def someComputation(L: Logarithms)(init: L.Logarithm): L.Logarithm = ...

Boxing Overhead

Type abstractions, such as type Logarithm erase to their bound (which is Any in our case). That is, although we do not need to manually wrap and unwrap the Double value, there will be still some boxing overhead related to boxing the primitive type Double.

Opaque Types

Instead of manually splitting our Logarithms component into an abstract part and into a concrete implementation, we can simply use opaque types in Scala 3 to achieve a similar effect:

object Logarithms:
//vvvvvv this is the important difference!
  opaque type Logarithm = Double

  object Logarithm:
    def apply(d: Double): Logarithm = math.log(d)

  extension (x: Logarithm)
    def toDouble: Double = math.exp(x)
    def + (y: Logarithm): Logarithm = Logarithm(math.exp(x) + math.exp(y))
    def * (y: Logarithm): Logarithm = x + y

The fact that Logarithm is the same as Double is only known in the scope where Logarithm is defined, which in the above example corresponds to the object Logarithms. The type equality Logarithm = Double can be used to implement the methods (like * and toDouble).

However, outside of the module the type Logarithm is completely encapsulated, or “opaque.” To users of Logarithm it is not possible to discover that Logarithm is actually implemented as a Double:

import Logarithms.*
val log2 = Logarithm(2.0)
val log3 = Logarithm(3.0)
println((log2 * log3).toDouble) // prints 6.0
println((log2 + log3).toDouble) // prints 4.999...

val d: Double = log2 // ERROR: Found Logarithm required Double

Even though we abstracted over Logarithm, the abstraction comes for free: Since there is only one implementation, at runtime there will be no boxing overhead for primitive types like Double.

Summary of Opaque Types

Opaque types offer a sound abstraction over implementation details, without imposing performance overhead. As illustrated above, opaque types are convenient to use, and integrate very well with the Extension Methods feature.

Contributors to this page: