|
| 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 n if n > 0 => 0 |
| 97 | + case 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) => if ((n: Num).ordinal == -1) None else 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