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.