Skip to content
This repository was archived by the owner on Jan 21, 2026. It is now read-only.

Commit dcc046d

Browse files
committed
Clean up description of opaque types
1 parent e54e15b commit dcc046d

2 files changed

Lines changed: 98 additions & 19 deletions

File tree

src/pages/types/constraints.typ

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ For example, if we have `EmailAddress` and `VerifiedEmailAddress` types,
3232
the compiler will tell us if we try to use an `EmailAddress` where a `VerifiedEmailAddress` is required.
3333
This brings us to two principles:
3434

35-
1. Types should represent what we know about values, or in other words the invariants or constraints on values. A `String` could be any sequence of characters. `VerifiedEmailAddress` is also a sequence of characters, but it's one that represents an email address that we have verified is active.
35+
1. Types should represent what we know about values, or in other words the invariants or constraints on values. A `String` could be any sequence of characters. A `VerifiedEmailAddress` is also a sequence of characters, but it's one that represents an email address that we have verified is active.
3636

3737
2. Whenever we establish an additional invariant or constraint we should change the type to reflect this additional information. So for example, an email address might start out as a `String`, become an `EmailAddress` if we have verified it looks like an email, and then become a `VerifiedEmailAddress` when we've successfully sent it a verification email and received a response.
38+
39+
40+
=== Constraints Backwards and Forwards

src/pages/types/opaque.typ

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,26 @@ Let's now look at opaque types.
55
Opaque types are a Scala 3 feature that decouple the representation of a type from the set of allowed operations on that type.
66
In simpler words, they allow us to create a type (e.g. an `EmailAddress`) that has the same runtime representation as another type (e.g. a `String`),
77
but is distinct from that type in all other ways.
8-
For example, here's a definition of `EmailAddress` as an opaque type.
8+
9+
Here's a definition of `EmailAddress` as an opaque type.
910

1011
```scala mdoc:silent
1112
opaque type EmailAddress = String
13+
```
14+
15+
This is enough to define the type `EmailAddress` as represented by a `String`.
16+
However, it's a useless definition as it lacks any way to construct an `EmailAddress`.
17+
To properly understand how we can define a constructor, we need to understand that opaque types divide our code base into two distinct parts: that where our type is transparent, where we know the underlying representation, and the remainder where it is opaque.
18+
The rule is pretty simple: an opaque type is transparent within the scope in which it is defined, so within an enclosing object or class.
19+
If there is no enclosing scope, as in the example above,
20+
it is transparent only within the file in which it is defined.
21+
Everywhere else it is opaque.
22+
23+
Knowing this we can define a constructor.
24+
Following Scala convention we will define it as the `apply` method on the `EmailAddress` companion object.
25+
26+
```scala mdoc:reset:silent
27+
opaque type EmailAddress = String
1228
object EmailAddress {
1329
def apply(address: String): EmailAddress = {
1430
assert(
@@ -44,9 +60,6 @@ object Opaque {
4460
import Opaque.*
4561
```
4662

47-
In addition to the `opaque type` definition of `EmailAddress` itself,
48-
notice that I also defined a constructor to create an `EmailAddress` from a `String`.
49-
This is the `apply` method on the `EmailAddress` companion object.
5063
The constructor does a basic check on the input (ensuring it contains only one `@` character)
5164
and converts the input to lower case, as email addresses are case insensitive.
5265
I used an `assert` to do the check,
@@ -66,7 +79,7 @@ We cannot, for example, call methods defined on `String` on an instance of `Emai
6679
#footnote[
6780
Scala usually runs on the JVM, and the JVM was not designed to support opaque types.
6881
This means there are, unfortunately, a few ways to poke holes in the abstraction boundary created by an opaque type.
69-
If we use `isInstanceOf`, we can test for the underlying representation.
82+
If we use `isInstanceOf` we can test for the underlying representation.
7083
Using the methods defined on `Object` (`Any` in Scala), namely `equals`, `hashCode`, and `toString`, also allow us to peek inside.
7184
]
7285

@@ -88,16 +101,10 @@ and our email addresses are case insensitive.
88101
We've seen how to define opaque types and their constructors.
89102
What about other methods?
90103
For example, for an `EmailAddress` we might want to get the username and domain.
104+
We can use extension methods to do this.
105+
As with the constructor, we just need to define these extension methods in a place where the type is transparent.
91106

92-
To properly understand how opaque types work, we need to understand they divide our code base into two distinct parts: those where our type is transparent, where we know the underlying representation, and the remainder where it is opaque.
93-
The rule is pretty simple: an opaque type is transparent within the scope in which it is defined, so within an enclosing object or class.
94-
If there is no enclosing scope, as in the example above,
95-
it is transparent only within the file in which it is defined.
96-
Everywhere else it is opaque.
97-
98-
Where the type is transparent we can define extension methods that add whatever functionality we need. Let's see an example, adding `username` and `domain` methods to our `EmailAddress`.
99-
100-
```scala mdoc:reset-object
107+
```scala mdoc:reset:silent
101108
opaque type EmailAddress = String
102109
extension (address: EmailAddress) {
103110
def username: String =
@@ -120,10 +127,79 @@ object EmailAddress {
120127
}
121128
```
122129

123-
As this
130+
```scala mdoc:invisible
131+
val email = EmailAddress("someone@example.com")
132+
```
133+
134+
With this definition we can use the extension methods as we'd expect.
135+
136+
```scala mdoc
137+
email.username
138+
email.domain
139+
```
140+
141+
=== Best Practices
142+
143+
We've seen all the important technical details for opaque types,
144+
so let's now discuss some of the best practices---the craft---of using them.
145+
146+
The first point I want to address is the constructor. "Types as constraints" is the strategy we're covering in this chapter.
147+
There is a constraint on the `String` input to the constructor: it must contain an `@` character.
148+
We should represent this as a type!
149+
We could create another opaque type, called something like `StringWithAnAtCharacter`, but this approaches leads to infinite regress.
150+
We cannot push constraints forward indefinitely.
151+
At some point we have to work with primitive types and return a result that indicates the possibility of error.
152+
So our constructor would be better if it returned, say, an `Option` or `Either` to indicate that construction can fail.
153+
154+
There are cases where we know the constructor cannot fail,
155+
but we don't have a convenient way of proving this to the compiler.
156+
For example, if we're loading email addresses from a list that is known to be good, it would be nice to avoid having to writing useless error handling code.
157+
For this reason I recommend including a constructor that doesn't do any validation.
158+
I usually call this method `unsafeApply`, to indicate to the reader that certain checks are not being done.
159+
These changes are shown below.
160+
For simplicity I've used `Option` as the result type.
161+
162+
```scala mdoc:reset:silent
163+
type EmailAddress = String
164+
object EmailAddress {
165+
def apply(address: String): Option[EmailAddress] = {
166+
val idx = address.indexOf('@')
167+
if idx != -1 && address.lastIndexOf('@') == idx then Some(address.toLowerCase)
168+
else None
169+
}
170+
171+
def unsafeApply(address: String): EmailAddress = address
172+
}
173+
```
174+
175+
At some point we'll almost certainly need to convert from our opaque type back to its underlying type.
176+
I've seen a few conventions for naming such a method; `value` and `get` are popular.
177+
However, I prefer a more descriptive `toType`, replacing `Type` with the concreate type name,
178+
as this extends to conversions to other types.
179+
For `EmailAddress` this means an extension method `toString`, as shown below.
180+
Notice that the method simply returns the `address` value,
181+
once showing the distinction between the type and it's representation as a value.
182+
183+
```scala mdoc:silent
184+
extension (address: EmailAddress) {
185+
def toString: String = address
186+
}
187+
```
188+
189+
190+
=== Beyond Opaque Types
191+
192+
Opaque types are a lightweight way to add structure---to use types to represent constraints---to our code. However there are two cases where they aren't appropriate.
193+
194+
The first case is when the data requires more structure that we can represent with an opaque type.
195+
For example, a (two-dimensional) point requires two coordinates, so there is no single type that we can use#footnote[
196+
We could use an `Array[Double]` or `Tuple2[Double, Double]`,
197+
but at this point it's simpler to just define a class in the usual way.
198+
].
199+
In these cases, we're probably looking for an algebraic data type. They are discussed in @sec:adt.
124200

125201

126-
Extension methods.
127-
- Defined on companion object?
202+
The second case is when you need to reimplement one of the methods, most commonly `toString`, that opaque types cannot override.
203+
If we're creating types that represent personal information such as addresses and passwords, we might want to ensure they cannot be accidentally exposed in logs.
204+
Overriding `toString` helps ensure this, but we cannot do this for opaque types.
128205

129-
equals and toString

0 commit comments

Comments
 (0)