You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: src/pages/types/constraints.typ
+4-1Lines changed: 4 additions & 1 deletion
Original file line number
Diff line number
Diff line change
@@ -32,6 +32,9 @@ For example, if we have `EmailAddress` and `VerifiedEmailAddress` types,
32
32
the compiler will tell us if we try to use an `EmailAddress` where a `VerifiedEmailAddress` is required.
33
33
This brings us to two principles:
34
34
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.
36
36
37
37
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.
Copy file name to clipboardExpand all lines: src/pages/types/opaque.typ
+94-18Lines changed: 94 additions & 18 deletions
Original file line number
Diff line number
Diff line change
@@ -5,10 +5,26 @@ Let's now look at opaque types.
5
5
Opaque types are a Scala 3 feature that decouple the representation of a type from the set of allowed operations on that type.
6
6
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`),
7
7
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.
9
10
10
11
```scala mdoc:silent
11
12
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
12
28
object EmailAddress {
13
29
def apply(address: String): EmailAddress = {
14
30
assert(
@@ -44,9 +60,6 @@ object Opaque {
44
60
import Opaque.*
45
61
```
46
62
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.
50
63
The constructor does a basic check on the input (ensuring it contains only one `@` character)
51
64
and converts the input to lower case, as email addresses are case insensitive.
52
65
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
66
79
#footnote[
67
80
Scala usually runs on the JVM, and the JVM was not designed to support opaque types.
68
81
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.
70
83
Using the methods defined on `Object` (`Any` in Scala), namely `equals`, `hashCode`, and `toString`, also allow us to peek inside.
71
84
]
72
85
@@ -88,16 +101,10 @@ and our email addresses are case insensitive.
88
101
We've seen how to define opaque types and their constructors.
89
102
What about other methods?
90
103
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.
91
106
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
101
108
opaque type EmailAddress = String
102
109
extension (address: EmailAddress) {
103
110
def username: String =
@@ -120,10 +127,79 @@ object EmailAddress {
120
127
}
121
128
```
122
129
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.
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.
124
200
125
201
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.
0 commit comments