Skip to content

Commit 7d056bc

Browse files
authored
Merge pull request #2027 from dwijnand/sealed-types
2 parents cd6318a + f65fe5c commit 7d056bc

File tree

1 file changed

+278
-0
lines changed

1 file changed

+278
-0
lines changed

_sips/sips/2021-05-18-sealed-types.md

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
---
2+
layout: sip
3+
title: Sealed Types
4+
vote-status: pending
5+
permalink: /sips/:title.html
6+
redirect_from: /sips/pending/sealed-types.html
7+
---
8+
9+
**By: Dale Wijnand and Fengyun Liu**
10+
11+
## History
12+
13+
| Date | Version |
14+
|---------------|-----------|
15+
| May 18th 2021 | Submitted |
16+
17+
## Introduction
18+
19+
Exhaustivity checking is one of the safety belts for using pattern matching in functional
20+
programming. However, if one wants to partition values of an existing type to reason about them in
21+
separate cases of a match or as separate types this requires wrapping the values in new classes,
22+
which incurs a boxing cost.
23+
24+
As an alternative, one may define custom extractors and use them as the case patterns. However, there is no
25+
way in Scala to declare that these extractors are complementary, i.e. the match is exhaustive when
26+
the complete set of extractors are used together.
27+
28+
Similarly, one can define `TypeTest`s for matching abstract types, but there is no way to determine
29+
if a match on an abstract type exhausts all its subtypes.
30+
31+
This SIP solves these three problems by introducing *sealed types*.
32+
33+
## Motivating Examples
34+
35+
We've identified several real world use cases that calls for enhancing exhaustivity checking, and
36+
used them to stress the design proposed here. You can find them [here][problems], but we'll present
37+
an example below.
38+
39+
Using the opaque types and custom extractors we can work safely with positive integers and negative
40+
integers:
41+
42+
```scala
43+
opaque type Pos <: Int = Int
44+
opaque type Neg <: Int = Int
45+
46+
object Pos { def unapply(x: Int): Option[Pos] = if (x > 0) Some(x) else None }
47+
object Neg { def unapply(x: Int): Option[Neg] = if (x < 0) Some(x) else None }
48+
49+
(n: Int) match
50+
case 0 =>
51+
case Pos(x) =>
52+
case Neg(x) =>
53+
```
54+
55+
With the above, when we get a `Pos` value, it's guaranteed to be a positive number. The same goes
56+
for `Neg`. Sadly the match is reported as not exhaustive because the two extractors and the value
57+
`0` aren't known to be complementary.
58+
59+
## Design
60+
61+
We identify two root causes:
62+
63+
1. You can't define a type, that isn't a class, as `sealed`
64+
2. You can't define a mapping from values to types
65+
66+
The *sealed type*, proposed by this SIP, allow partitioning of value of a given type into a sealed
67+
type hierarchy. [Here][solutions] you can find how sealed types address the issues faced by all the
68+
motivating examples, but we'll present here how it fixes the number example above.
69+
70+
In order to partition int into positive, zero, and negative numbers we'll define a new `sealed` type
71+
`Num` and how to distinguish its subtypes with match syntax:
72+
73+
```scala
74+
sealed type Num = Int {
75+
case 0 => val Zero
76+
case n if n > 0 => type Pos
77+
case _ => type Neg
78+
}
79+
```
80+
81+
This sealed type definition desugars into the following type and value definitions:
82+
83+
```scala
84+
type Num = Int
85+
val Zero: Num = 0
86+
opaque type Pos <: Num = Num
87+
opaque type Neg <: Num = Num
88+
```
89+
90+
The match desugars into an ordinal method, that reuses the logic to associate an ordinal for each
91+
case:
92+
93+
```scala
94+
extension (n: Num):
95+
def ordinal: Int = (n: Int) match {
96+
case 0 => 0
97+
case n if n > 0 => 1
98+
case _ => 2
99+
}
100+
```
101+
102+
Finally a series of `TypeTest`s are defined, allowing for values of both the underlying type `Int`
103+
and the sealed type `Num` to be tested against the subtypes and singleton subtypes `Pos`,
104+
`Zero.type`, and `Neg`, using the `ordinal` method:
105+
106+
```scala
107+
given TypeTest[Int, Zero.type] = (n: Int) => if ((n: Num).ordinal == 0) Some(n) else None
108+
given TypeTest[Int, Pos] = (n: Int) => if ((n: Num).ordinal == 1) Some(n) else None
109+
given TypeTest[Int, Neg] = (n: Int) => if ((n: Num).ordinal == 2) Some(n) else None
110+
given TypeTest[Int, Num] = (n: Int) => Some(n)
111+
given [T <: Num](using t: TypeTest[Int, T]): TypeTest[Num, T] = (n: Num) => t.unapply(n)
112+
```
113+
114+
Given the above, one can either change the usage from extractors to types:
115+
116+
```scala
117+
(n: Int) match
118+
case 0 =>
119+
case x: Pos =>
120+
case x: Neg =>
121+
```
122+
123+
Or we can keep the usage the same by redefining the extractors (using a value class name-based
124+
extractors `PosExtractor` and `NegExtractor` to avoid allocating):
125+
126+
```scala
127+
object Pos { def unapply(x: Pos): PosExtractor = new PosExtractor(x) }
128+
object Neg { def unapply(x: Neg): NegExtractor = new NegExtractor(x) }
129+
130+
class PosExtractor(private val x: Pos) extends AnyVal { def isEmpty: false = false ; def get = x }
131+
class NegExtractor(private val x: Neg) extends AnyVal { def isEmpty: false = false ; def get = x }
132+
133+
(n: Int) match
134+
case 0 =>
135+
case Pos(x) =>
136+
case Neg(x) =>
137+
```
138+
139+
## Syntax
140+
141+
The existing syntax is enhanced as follows:
142+
143+
```
144+
TypeDcl ::= `sealed` [`opaque`] `type` id [TypeParamClause]
145+
TypeDef ::= `sealed` [`opaque`] `type` id [TypeParamClause] [`>:` Type] [`<:` Type] `=` Type `{`
146+
`case` Pattern [Guard] `=>` (`type` id [TypeParamClause] | `val` id [`:` Type])
147+
`}`
148+
```
149+
150+
Specifically:
151+
152+
* the `sealed` modifier becomes available for type definitions and declarations
153+
* on the right-hand side of definitions is the underlying type of the sealed type
154+
* following the underlying type is a match that operates on a value of the
155+
underlying type and defines the type or singleton type associated to that case.
156+
* the type is defined using the `type` keyword and singleton types are defined using `val`
157+
158+
## Desugaring
159+
160+
Using the example
161+
162+
```
163+
sealed [opaque] type T[X..] [bounds] = U {
164+
case p1 => type C[X..]
165+
case p2 => val S
166+
}
167+
```
168+
169+
That desugars into:
170+
* a type alias `type T[X..] [bounds] = U`, `opaque` if the sealed type is `opaque` (see Restrictions)
171+
* opaque type definitions, `opaque type C[X..] <: T[Y..] = T[Y..]`, for each non-singleton type case
172+
- any type argument `Y` that isn't defined from `X..` will be:
173+
+ its lower bound, if the type parameter is covariant
174+
+ its upper bound, if the type parameter is contravariant
175+
+ a wildcard type, with the type parameter's bounds, if the type parameter is invariant
176+
* val definitions, `val S: T[Y..] = p`, for singleton type cases, using the same type argument rules
177+
* an ordinal method, `extension (t: T): def ordinal: Int = (t: U) match { case p1 => 0 p2 => 1 .. }`
178+
- each of the sealed type's cases is associated with a unique ordinal
179+
- ordinals starts from 0 and increase for each case, in the order of their definition
180+
- the ordinal method adds a `case _ => -1` default case, if the sealed type's match is inexhaustive
181+
- such an ordinal method may only be defined by the compiler, to preserve exhaustivity guarantees
182+
* a series a `TypeTest`s, defined in terms of the ordinal method
183+
- a `TypeTest` between `U` and each case `A`, having ordinal `ordA`:
184+
+ `given TypeTest[U, C] = (u: U) => if ((u: T).ordinal == $ordA) Some(u) else None`
185+
+ `given TypeTest[U, S.type] = (u: U) => if ((u: T).ordinal == $ordA) Some(u) else None`
186+
- a type test between the underlying type and the sealed type:
187+
+ `given TypeTest[U, T] = (u: U) => if ((u: T).ordinal == -1) None else Some(u)`
188+
- a generic type test between `T` and each case `A`, defined in terms of the above type tests:
189+
+ `given [A <: T](using t: TypeTest[U, A]): TypeTest[T, A] = (x: T) => t.unapply(x)`
190+
191+
## Restrictions
192+
193+
1. If the match on the value of the underlying type is not exhaustive, then the sealed type must be
194+
declared `opaque`, in order to preserve the fact that the sealed type represents only a subset of
195+
the values of the underlying type (e.g. positive integers)
196+
2. No other type may be declared to subtype the opaque type `T`
197+
3. For singleton types, the pattern `p` must be a stable value, e.g. a `val`, a `case object`, or a literal
198+
4. Each case must define a new type or singleton type
199+
200+
## Alternative Design
201+
202+
An alternative design is to introduce an annotation `@complete` to specify that
203+
a type can be partitioned into a list of subtypes.
204+
205+
For example, given the following definition:
206+
207+
```scala
208+
opaque type Nat <: Int = Int
209+
opaque type Neg <: Int = Int
210+
211+
@complete[(Nat, Neg)] // Num decomposes to `Nat` and `Neg`
212+
type Num = Int
213+
214+
given TypeTest[Int, Neg] = (n: Int) => if (x < 0) Some(n) else None
215+
given TypeTest[Int, Nat] = (n: Int) => if (x >= 0) Some(n) else None
216+
```
217+
218+
The user now can write code as follows:
219+
220+
``` Scala
221+
def foo(n: Num) =
222+
n match
223+
case x: Neg =>
224+
case x: Nat =>
225+
```
226+
227+
Knowing that the type `Num` can be decomposed to `Neg` and `Nat`, the compiler
228+
can verify that the pattern match above is exhaustive.
229+
230+
This approach, however, is relatively low-level and the compiler does not
231+
provide any guarantee that the annotation is actually correct.
232+
233+
You can find more examples [here][complete-gist]
234+
235+
## Related Work
236+
237+
Haskell has a `COMPLETE` pragma which allows patterns and type constructors to be
238+
defined to be a complete set, relying on the programmer getting it right.
239+
240+
```haskell
241+
data Choice a = Choice Bool a
242+
243+
pattern LeftChoice :: a -> Choice a
244+
pattern LeftChoice a = Choice False a
245+
246+
pattern RightChoice :: a -> Choice a
247+
pattern RightChoice a = Choice True a
248+
249+
{-# COMPLETE LeftChoice, RightChoice #-}
250+
251+
foo :: Choice Int -> Int
252+
foo (LeftChoice n) = n * 2
253+
foo (RightChoice n) = n - 2
254+
```
255+
256+
## References
257+
258+
1. [Opaque types][1]
259+
2. [Forum discussion about Opt[T]][2]
260+
3. [Github discussion about enhancing exhaustivity check][3]
261+
4. [_Lightweight static capabilities_][4], Oleg Kiselyov, Chung-chieh Shan, 2007
262+
5. [TypeTest documentation][5]
263+
264+
[1]: https://docs.scala-lang.org/sips/opaque-types.html
265+
[2]: https://contributors.scala-lang.org/t/trouble-with-2-13-4-exhaustivity-checking-being-too-strict/4817
266+
[3]: https://github.com/lampepfl/dotty/issues/10961
267+
[4]: http://okmij.org/ftp/Computation/lightweight-guarantees/lightweight-static-capabilities.pdf
268+
[5]: http://dotty.epfl.ch/docs/reference/other-new-features/type-test.html
269+
[6]: https://github.com/lampepfl/dotty/pull/11186
270+
[problems]: https://gist.github.com/dwijnand/d33436cf197daa15216b3cd35d03ba1c#file-sealedtypeproblems-scala
271+
[solutions]: https://gist.github.com/dwijnand/d33436cf197daa15216b3cd35d03ba1c#file-sealedtypesolutions-scala
272+
[complete-gist]: https://gist.github.com/dwijnand/d33436cf197daa15216b3cd35d03ba1c#file-z-complete-scala
273+
274+
* https://github.com/lampepfl/dotty/issues/10961 False “match may not be exhaustive warning”
275+
* https://github.com/lampepfl/dotty/pull/11186 Implement @covers annotation for partial irrefutable specification
276+
* https://downloads.haskell.org/~ghc/9.0.1/docs/html/users_guide/exts/pragmas.html#complete-pragma
277+
* https://downloads.haskell.org/~ghc/9.0.1/docs/html/users_guide/exts/pattern_synonyms.html
278+
* https://dotty.epfl.ch/docs/reference/other-new-features/opaques.html

0 commit comments

Comments
 (0)