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

Commit 1f2e4ea

Browse files
committed
Editing pass on types as constraints chapter
1 parent e85b077 commit 1f2e4ea

5 files changed

Lines changed: 158 additions & 67 deletions

File tree

src/pages/types/conclusions.typ

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
#import "../stdlib.typ": narrative-cite, href
22
== Conclusions <sec:types:conclusions>
33

4+
In this chapter we've looked at using types to represent constraints,
5+
which allows the compiler to help us ensure these constraints are met throughout our program.
6+
We call this strategy "types as constraints".
7+
We constrasted this strategy to the better known view of types that focuses on representation.
8+
Finally, we saw opaque types as a lightweight tool that decouples types from their representation,
9+
allowing us to define a type that uses the same representation as some other type.
10+
411
The view of types as constraints is perhaps best presented in Alexis King's blog post #href("https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/")[Parse, don't validate].
12+
#narrative-cite(<morris73:types>) is a very early paper (typewritten in two column justified text, a truly virtuoso performance on the type writer!) that also presents the intensional view of types. I feel it ends a bit abruptly, but has the seed of many ideas that will only be fully developed much later. You can see the suggestion of opaque types as discussed in this chapter, and also module systems and existential types.
513

614
From a programming language perspective, #narrative-cite(<pierce02:tapl>) is the standard reference on type systems.
715
They define a type system as "a tractable syntactic method for proving the absence of certain program behaviours by classifying phrases by the kinds of values they compute".
816
The introduction provides a very nice overview of the role of type systems in programming languages, as well as pointers to the broader study of type systems in mathematics and philosophy.
917

10-
#narrative-cite(<morris73:types>) is a very early paper (typewritten in two column justified text, a truly virtuoso performance on the type writer!) that presents the intensional view of types. I feel it ends a bit abruptly, but has the seed of many ideas that will only be fully developed much later. You can see the suggestion of opaque types as discussed in this chapter, and also module systems and existential types.
11-
12-
Having said that types are not sets, it feels only fair to mention there are type systems that treat types as sets. #narrative-cite(<castagna23:elixir>) describes one such system.
18+
Having said that types are not sets, it feels only fair to mention there are type systems that do treat types as sets. #narrative-cite(<castagna23:elixir>) describes one such system.
1319
These type systems emphasize the extensional view, and have a very different feel to conventional type systems.
1420

1521
I'm very far from an expert in mathematical type theory. As such, I found #narrative-cite(<klev19:comparison>) useful to relate type theory to something I better understand, set theory.

src/pages/types/constraints.typ

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,22 @@
33

44
Most applications work by progressively adding structure to inputs.
55
We might receive data from, say, the network or a database.
6-
We perform some checks on that data, and remove instances that are invalid.
6+
We perform some checks on that data and remove instances that are invalid.
77
We then do some more work, which entails further checks, and so on.
88

99
For example, imagine we're implementing a sign up flow.
1010
We start by asking for a user name and email address.
1111
Basic checks could be requiring names that are not empty, and email addresses that contain an `@`.
1212
We won't even let the user submit the form if these checks fail.
13-
If this looks ok, we'll move on to further checks.
14-
For example, we might validate email addresses
15-
by sending them a verification email.
13+
Once the form is submitted we'll move on to further checks.
14+
For example, we might validate email addresses by sending them a verification email.
1615

1716
How should we represent these multiple levels of validation?
1817
For example, how do we distinguish a string representing a name from one that is an email address?
1918
How about an unverified email from a verified one?
20-
If you've worked on enough projects you've probably seen many approaches to this.
21-
Many code bases use names in an ad hoc do this.
19+
The most common approach that I've seen, across many code bases, is to use ad hoc naming conventions.
2220
For example, we might use the name `email` and `verifiedEmail` to distinguish the different kinds of email addresses
23-
in method parameters and data structure members,
21+
in method parameters and data structure,
2422
while still representing both as strings.
2523
#footnote[
2624
#href("https://en.wikipedia.org/wiki/Hungarian_notation")[Hungarian notation] is a more formal approach to
@@ -30,13 +28,14 @@ while still representing both as strings.
3028
]
3129

3230
Types provide a compelling alternative to naming schemes.
33-
It provides all the advantages of naming schemes,
34-
while also representing this information in a form the compiler can check for us.
31+
They enforce consistency of nomenclature,
32+
while also representing this information in a form the compiler can check.
3533
For example, if we have `EmailAddress` and `VerifiedEmailAddress` types,
36-
the compiler will tell us if we try to use an `EmailAddress` where a `VerifiedEmailAddress` is required,
34+
not only do we have standard names,
35+
but the compiler will tell us if we try to use an `EmailAddress` where a `VerifiedEmailAddress` is required,
3736
or a `String` where an `EmailAddress` is required.
3837
Furthermore, when we see an `EmailAddress` we know it's already been through some validation,
39-
so we don't need to repeat this validation (or worse, forget to do it.)
38+
so we don't needlessly repeat validation, or worse, forget to do it.
4039
This brings us to two principles:
4140

4241
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.
@@ -57,7 +56,7 @@ def domain(email: String): Option[String]
5756

5857
indicating that the `String` might not be a valid email.
5958
In this case we push the error handling,
60-
which reflects the constraint that we only work with valid email addresses,
59+
reflecting the constraint that we only work with valid email addresses,
6160
onto the downstream code that deals with the result of calling this method.
6261

6362
When we work with types as constraints the signature becomes
@@ -69,22 +68,23 @@ def domain(email: EmailAddress): String
6968
There is now no possibility of error, as an email address must contain a domain.
7069
However, we have pushed the constraint, obtaining an `EmailAddress`,
7170
onto the upstream code that calls this method.
72-
At some point we must bottom out and have conversions that could fail,
71+
At some point we must have conversions that could fail,
72+
which requires error handling,
7373
but this approach pushes error handling to the edges of the program.
7474
This tends to result in a better user experience,
7575
as the user is immediately notified of problems,
76-
and also makes the code simpler to work with as less error handling is required.
76+
and also makes the code simpler to work with as overall less error handling is required.
7777

78-
Finally, although this strategy is easiest to explain in the context of validation,
79-
it's not restricted to only this use.
80-
As an example, let's think about writing code that presents an API for updates to a database table.
78+
Although this strategy is easiest to explain in the context of validation,
79+
it's not restricted to this use case.
80+
As an example, imagine writing an API for updates to a database table.
8181
Some columns allow nulls and some do not.
82-
When updating a nullable column we could accept an `Option`,
83-
with the `None` case meaning setting the column to null.
82+
When updating a nullable column our API could accept an `Option`,
83+
with the `None` case meaning the column is set to null.
8484
When updating a non-nullable column we could also accept an `Option`,
85-
with the `None` case meaning we retain the existing value.
85+
with the `None` case meaning we retain the column's existing value.
8686
These two different meaning of the same type are a sure way to introduce errors,
8787
with users nulling out columns they intended to leave unchanged.
88+
The solution is the same: use different types for the different kinds of columns.
8889
Here the constraints are not on the values represented by the type,
89-
but on the behaviour associated with the type.
90-
The same type should not have different behaviours in the same API.
90+
but on the behaviour associated with them.

src/pages/types/index.typ

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33

44
Our very first strategy is using *types as constraints*.
55
We'll start by discussing two different ways we can think of types:
6-
by what it is, sometimes known as an *extensional* view; and
7-
by what it can do, sometimes known as an *intensional* view.
8-
The latter view is not as well known,
6+
by a type is, sometimes known as an *extensional* view; and
7+
by what a type can do, sometimes known as an *intensional* view.
8+
The latter view is less familiar,
99
but is necessary to get the most from an expressive type system
1010
and is the core of the strategy.
11-
Hence we'll spend some time elaborating on this idea, and discussing examples.
11+
Hence we'll spend some time elaborating on this idea and discussing examples.
1212

1313
Once we understand the concept of types as constraints,
1414
we'll look at a Scala 3 feature, known as *opaque types*.
15-
Opaque types allow us to create distinct types that have the same runtime representation as another type.
15+
Opaque types allow us to create a distinct type that has the same runtime representation as another type.
1616
As such, they provide a way to decouple representation from operations,
1717
and allow us to work with a purely intensional view.

src/pages/types/opaque.typ

Lines changed: 105 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ opaque type EmailAddress = String
1414

1515
This is enough to define the type `EmailAddress` as represented by a `String`.
1616
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.
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: where our type is transparent, which is where we know the underlying representation, and the remainder where it is opaque.
1818
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.
1919
If there is no enclosing scope, as in the example above,
2020
it is transparent only within the file in which it is defined.
@@ -94,13 +94,13 @@ yet it is a different type.
9494
Alternatively, we can view it as a semantic gain.
9595
An `EmailAddress` _is_ a sequence of characters,
9696
the same as a `String`,
97-
but it has additional properties.
97+
but it has additional constraints.
9898
In this case we verify it contains exactly one `@` character,
99-
and our email addresses are case insensitive.
99+
and ensure it is case insensitive.
100100

101101
We've seen how to define opaque types and their constructors.
102102
What about other methods?
103-
For example, for an `EmailAddress` we might want to get the username and domain.
103+
For example, for an `EmailAddress` we might want methods to get the username and domain.
104104
We can use extension methods to do this.
105105
As with the constructor, we just need to define these extension methods in a place where the type is transparent.
106106

@@ -138,17 +138,108 @@ email.username
138138
email.domain
139139
```
140140

141+
There are two other features of opaque types that we should mention:
142+
143+
1. they can have type parameters; and
144+
2. they can have type bounds.
145+
146+
Let's see an example of these two features used together.
147+
Earlier we saw an example of using an `Option` to represent two different types of database columns:
148+
nullable columns, where `None` mean to set the column to null, and non-nullable, where `None` means to keep the existing value.
149+
We can define these as opaque types with a type parameter.
150+
151+
```scala mdoc:silent
152+
// null is a reserved word in Scala, so we use the name nil
153+
// instead.
154+
opaque type Nilable[+A] = Option[A]
155+
object Nilable {
156+
def apply[A](value: A): Nilable[A] = Some(value)
157+
158+
def fromOption[A](option: Option[A]): Nilable[A] = option
159+
160+
val nil: Nilable[Nothing] = None
161+
}
162+
163+
opaque type Default[+A] = Option[A]
164+
object Default {
165+
def apply[A](value: A): Default[A] = Some(value)
166+
167+
def fromOption[A](option: Option[A]): Default[A] = option
168+
169+
val default: Default[Nothing] = None
170+
}
171+
```
172+
173+
This works just as we'd expect, but we users will probably want to use the `Option` API on `Nilable` and `Default`.
174+
We can avoid tediously reimplementing it as extension methods by declaring that `Nilable` and `Default` are subtypes of `Option`.
175+
176+
```scala mdoc:reset:silent
177+
opaque type Nilable[+A] <: Option[A] = Option[A]
178+
object Nilable {
179+
def apply[A](value: A): Nilable[A] = Some(value)
180+
181+
def fromOption[A](option: Option[A]): Nilable[A] = option
182+
183+
val nil: Nilable[Nothing] = None
184+
}
185+
186+
opaque type Default[+A] <: Option[A] = Option[A]
187+
object Default {
188+
def apply[A](value: A): Default[A] = Some(value)
189+
190+
def fromOption[A](option: Option[A]): Default[A] = option
191+
192+
val default: Default[Nothing] = None
193+
}
194+
```
195+
196+
The type bound `Default[+A] <: Option[A]` says that `Default` is a subtype of `Option`,
197+
and crucially this information is publically available.
198+
Therefore all of the methods on `Option` are available on `Default`.
199+
200+
```scala mdoc:reset:invisible
201+
object Wrapper {
202+
opaque type Nilable[+A] <: Option[A] = Option[A]
203+
object Nilable {
204+
def apply[A](value: A): Nilable[A] = Some(value)
205+
def fromOption[A](option: Option[A]): Nilable[A] = option
206+
val nil: Nilable[Nothing] = None
207+
}
208+
209+
opaque type Default[+A] <: Option[A] = Option[A]
210+
object Default {
211+
def apply[A](value: A): Default[A] = Some(value)
212+
def fromOption[A](option: Option[A]): Default[A] = option
213+
val default: Default[Nothing] = None
214+
}
215+
}
216+
import Wrapper.*
217+
```
218+
219+
We can verify this with a few examples.
220+
221+
```scala mdoc
222+
Nilable(1).orElse(Nilable.nil)
223+
224+
Default(1).map(_ + 1)
225+
```
226+
227+
Notice that the results have type `Option`,
228+
because the methods on `Option` that we call have return type `Option`.
229+
We can easily convert back to `Nilable` or `Default` as required by using the `fromOption` constructor.
230+
231+
141232
=== Best Practices
142233

143234
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.
235+
so let's now discuss some of the best practices of using them.
145236

146-
The first point I want to address is the constructor. "Types as constraints" is the strategy we're covering in this chapter.
237+
The first point I want to address is illustrated by the constructor for `EmailAddress`.
147238
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.
239+
This is a constraint and we should represent this as a type!
240+
We could create another opaque type, called something like `StringWithAnAtCharacter`, but this approach leads to infinite regress.
241+
We cannot push constraints upstream indefinitely.
242+
At some point we have to return a result that indicates the possibility of error.
152243
So our constructor would be better if it returned, say, an `Option` or `Either` to indicate that construction can fail.
153244

154245
There are cases where we know the constructor cannot fail,
@@ -157,7 +248,7 @@ For example, if we're loading email addresses from a list that is known to be go
157248
For this reason I recommend including a constructor that doesn't do any validation.
158249
I usually call this method `unsafeApply`, to indicate to the reader that certain checks are not being done.
159250
These changes are shown below.
160-
For simplicity I've used `Option` as the result type.
251+
For simplicity I've used `Option` as the result type to indicate the possibility of failure.
161252

162253
```scala mdoc:reset:silent
163254
type EmailAddress = String
@@ -173,9 +264,9 @@ object EmailAddress {
173264
}
174265
```
175266

176-
At some point we'll almost certainly need to convert from our opaque type back to its underlying type.
267+
We'll almost certainly need to convert from our opaque type back to its underlying type at some point in our code.
177268
I've seen a few conventions for naming such a method; `value` and `get` are popular.
178-
However, I prefer a more descriptive `toType`, replacing `Type` with the concreate type name,
269+
However, I prefer a more descriptive `toType`, replacing `Type` with the concrete type name,
179270
as this extends to conversions to other types.
180271
For `EmailAddress` this means an extension method `toString`, as shown below.
181272
Notice that the method simply returns the `address` value,
@@ -197,12 +288,11 @@ For example, a (two-dimensional) point requires two coordinates, so there is no
197288
We could use an `Array[Double]` or `Tuple2[Double, Double]`,
198289
but it's simpler to just define a class in the usual way.
199290
].
200-
We also cannot define opaque types with type parameters.
201291
In these cases we're probably looking for an algebraic data type,
202292
which is discussed in @sec:adt.
203293

204294
The second case is when we need to reimplement one of the methods, most commonly `toString`, that opaque types cannot override.
205295
For example,
206-
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.
296+
we might want to ensure that types representing personal information, such as addresses and passwords, cannot be accidentally exposed in logs.
207297
Overriding `toString` helps ensure this, but we cannot do this for opaque types.
208298

0 commit comments

Comments
 (0)