Pre-SIP: A New Type for Optionals and Error Handling (2)

A New Type for Optionals and Error Handling

[I accidentally deleted a previous post with the same title. The original post is re-instantiated here.]

When it comes to optionals and error handling, do you prefer safety or convenience? You should not have to choose. After all, that’s Scala’s motto – combining safety and convenience in one package.

And yet, in this particular area there are real tradeoffs between the two, and they will only get worse.

Take optional data. You can express the absence of a value with None or with null. Of course, nulls are terribly unsafe, so Scala programmers have generally avoided them, with some exceptions: Java interop is one, high-performance code another. But with explicit nulls, the balance shifts a little. Nulls are now safer to use, because the type system knows whether a value can be null or not. That puts them closer to Option when it comes to safety. And nulls are both more convenient and more efficient than Option.

They are more convenient since we don’t have to wrap a value with Some to make it an Option. Say we have a function f that takes a parameter nickname of type String that could be undefined. If f was defined like this

  def f(nickname: Option[String])

we’d have to call it with f(Some("Pete")). The Some is annoying, since it is clear that "Pete" is not None, so no ceremonial wrapping should be needed. By contrast, if we define f like this

  def f(nickname: String | Null)

then we can call it with just f("Pete"). That’s not only more convenient, it is also more efficient, since no wrapper is needed.

On the other hand, even with explicit nulls, T | Null is not as safe as Option[T]. The problem is that T | Null is not parametric, which is to say that it does not always produce a type that’s different from T(String | Null) | Null is the same as String | Null. This is a problem if, for instance, you want to use null to signal a missing entry when looking up a value in a map. If lookup is defined like this

  def lookup[Key, Value](m: Map[Key, Value], k: Key): Value | Null

and Value is instantiated with String | Null, then a returned null is indistinguishable from a missing entry. In other words, abstractions using Null types are leaky and lead to fragile code.

So, even with explicit nulls arriving, there are still good reasons to stick with the parametric types Option or Either. It’s just a shame that these are less convenient and efficient.

But what if we don’t have to choose? What if there was a type constructor that is parametric and at the same time just as efficient as, and even more convenient than, unions with Null? Such a type constructor can be designed, if we assume a little bit of support from the compiler. The rest of this note explains how.

The best type for optional values

Ideally, the construct to express optional values should combine the best aspects of Option and union types. Like union types, it should require no ceremonial wrapping in Some if the intent is clear, which helps both readability and performance. But like Option, it should be parametric. And as an extra bonus, it should provide an easy way to upgrade from legacy code using nulls.

We can achieve this by designing a new type with carefully crafted semantics and subtyping and typing rules. Let’s call that new type T? (pronounced maybe T), acting as a replacement for Option[T].

T? is used in C#, Kotlin, and other languages to mean essentially T | Null. The type proposed here has a crucial difference that makes it parametric: internally, the maybe type T? can be seen as a union of three possible types, T, Null, and Valid.

  opaque type T? = T | Null | Valid

Valid is an internal type that can be represented by the following case class:

  case class Valid(elem: Any)

Valid(x) represents a “valid value”, even if the element x happens to be null. Compared to Option, we have the following analogies:

  null                  None
  Valid(null)           Some(None)
  Valid(Valid(null))    Some(Some(None))
  ...

In fact, Valid can only wrap elements that are either null or other Valid instances. But this is not enforced in its type signature, since Valid is hidden from user programs anyway. In place of Valid, there is a public-facing Ok data constructor that evaluates as follows:

  Ok(x)     --->     Valid(x)   if x == null or x is a Valid instance
            --->     x          otherwise

That is, Ok(x) is simply x, unless x is null or some wrapped version of null.

To take a maybe type apart, you can use a pattern match, just like for Option. Ok corresponds to Some, and null corresponds to None.

  def maybeReverse(s: String?): String? = s match
    case Ok(str) => str.reverse
    case null    => null

That pattern match can be compiled to very efficient code.

The representation of maybe types is very similar to @sjrd’s unboxed option type. The main differences are that maybe types identify null with None, and that they allow automatic widening through the following subtyping rules:

  • Null <: T?, for all types T
  • T <: T?, for all types T that are disjoint from Null. The disjointness test is exactly the test used for match type reduction.
  • T? <: T | Null, for all types T that are disjoint from Null.
  • The maybe type constructor is covariant: if T1 <: T2 then T1? <: T2?.

Combining these subtyping rules with the rules for union types, we can also derive that T | Null is equivalent to T? by mutual subtyping if T is known to be disjoint from Null, i.e. T | Null <: T? <: T | Null.

Under explicit nulls, Java types J often get mapped to J | Null. The equivalence means that we can treat these types as maybe types J?, as long as J is disjoint from Null (which is the most common case by far).

Erasure

The erasure of T? is the erasure of T if T is disjoint from Null, and Object otherwise. For instance, the following overloads are possible, since String and List[String] are concrete types that do not contain null:

  def f(x: String?) = ...
  def f(x: List[String]?) = ...

If we replace ? with Option, then the erasure of the two arguments would be the same, and we’d need a @targetName annotation on one of the methods.

Conversely, the following overloads would clash:

  def f[T](x: T?) = ...
  def f[T](x: T) = ...

Here, both T and T? erase to Object. On the other hand, the same example written with an Option argument would pass:

  def f[T](x: Option[T]) = ...
  def f[T](x: T) = ...

Evaluation

T? combines the best aspects of both Option[T] and T | Null.

  • Like Option[T], it is parametric. If type arguments A and B are different, then so are A? and B?.
  • If T is known to not contain null (i.e. in most cases), it can be widened automatically to T?, just like T | Null.
  • T? is practically as efficient as T | Null. For injection, most types are widened automatically, which is free. Even when injection goes through Ok(t), a single type test will in most cases establish that no wrapping is needed. An extra object is created only in the case where we do wrap null as a normal value, and this case should be rare. For decomposition, the situation is similar. If the type T is known to not contain null, decomposition of T? amounts to a single comparison with null, plus a downcast. Otherwise, we need one additional type test.

Since T? is also shorter to write than either T | Null or Option[T], there should be a natural tendency to make it the preferred solution for all new code.

The best type for error handling

T? generalizes naturally to a type that’s ideal for error handling. It can be seen as a special case of a result type T ? E, which can also carry additional error information of type E for missing values. So T ? E (pronounced result T or E) would be an alternative to Either[E, T].

To go from values T to results T ? E and back, we use Ok as before. For the error part, which was handled by just null for maybe types, we now use a new constructor and extractor Err. Example:

  def testPos(x: Int): Int ? String =
    if x >= 0 then x else Err(s"negative $x")

  def usePos(x: Int): Int = testPos(x) match
    case Ok(y) => y
    case Err(s) =>
      log(s)
      0

The maybe type T? is now simply an abbreviation for T ? Unit, a result type where the error component carries no particular information. One tricky aspect is that there are now two ways to signal an error for a maybe type: null and Err(()). The two ways must come down to the same representation. So we make sure in the Err constructor that Err(()) = null, and in the Err extractor that a null value matches an Err(()) pattern.

The mechanics of all this are a straightforward extension of the scheme for maybe types.

Internally, the result type T ? E can be seen as a union of four possible types:

  opaque type T ? E = T | Valid | Null | Fail[E]

Here, Fail is the type of invalid (error) values. Like Valid, it is an internal type. It can be represented by the following case class:

  case class Fail[+E](elem: E)

The Err constructor produces null if it is given a unit argument ().

Err(x)    --->     null      if x == ()
          --->     Fail(x)   otherwise

The Err pattern match goes the other way, producing a () error value when matching null.

The Ok constructor is now defined as follows:

  Ok(x)     --->    Valid(x)   if x == null or x is a `Valid` or `Fail` instance
            --->    x          otherwise

The subtyping rules subsume the ones for maybe types. We have additionally:

  • Fail[E] <: T ? E, for all types T and E.
  • T <: T ? E, if T is disjoint from both Null and Fail[Any].
  • The result type constructor is also covariant in its error part: if E1 <: E2 then T ? E1 <: T ? E2.

One error type to rule them all

The new type T ? E can express a panoply of existing types in Scala:

  Option[T]       ~~    T ? Unit  =  T?
  Either[E, T]    ~~    T ? E
  Try[T]          ~~    T ? Exception

Arguably, T ? E is more efficient and ergonomic than these types. For instance, compared to Either[E, T], T ? E is

  • more ergonomic, because you don’t need ceremonial Right(...) wrapping,
  • more efficient, because the runtime usually does not wrap either,
  • more intuitive, because result and error parts appear in the natural order.

Another big advantage is that T ? E is a single type with a large usability spectrum, covering several existing types. So you have to learn error handling patterns only once, and it becomes easier to build re-usable abstractions for error handling (more on that below).

On the other hand, the existing types won’t go away, and current and future code bases will surely continue to use them. This is fine. I foresee that adoption of maybe types and result types will begin in codebases where interop with Java is needed, and in greenfield projects where one can start from scratch. If T? manages to convince people not to use the non-parametric T | Null form, it’s already a win.

Higher Level Usage Patterns

Optionals and error handling are often used in higher-level abstractions. For instance, both Option and Either can be used in for expressions, which replace explicit pattern matching and construction with a higher-level monadic abstraction. Result types can do that as well. The standard library can define the appropriate map, flatMap and filter functions to make this work.

For instance, here are suitable extension methods for map and flatMap:

  extension [A, E](x: A ? E)

    def map[B](f: A => B): B ? E = x match
      case Ok(y) => Ok(f(y))
      case Err(e) => Err(e)

    def flatMap[B](f: A => B ? E): B ? E = x match
      case Ok(y) => f(y)
      case Err(e) => Err(e)

As an example of monadic error handling, consider the task of parsing a string as a date in the format “day/month/year”. For parsing integers, we define an extension method parseInt:

  extension (str: String) def parseInt: Int? =
    try str.toInt
    catch case ex: NumberFormatException => null

parseDate can then be written as follows:

  case class Date(day: Int, month: Int, year: Int)

  def parseDate(str: String): Date? =
    str.split("/") match
      case Array(d, m, y) =>
        for
          day <- d.parseInt
          month <- m.parseInt
          year <- y.parseInt
        yield
          Date(day, month, year)
      case _ =>
        null

In fact, this code would look exactly the same if we had used Option[T] instead of T?.

Direct Style

We can also define higher-level direct style abstractions that are more flexible and efficient than the monadic ones.

Since we already spent the postfix ? syntax on types, we might as well use the same syntax on terms to support direct style (Ox and Steps use .ok instead). So we define a postfix operator ? for terms as well. How it works is best illustrated by porting the parseDate function above to direct style:

  def parseDate(str: String): Date? =
    str.split("/") match
      case Array(d, m, y) =>
        maybe:
          Date(d.parseInt?, m.parseInt?, y.parseInt?)
      case _ =>
        null

Here, each usage of ? works on a left operand of type Int?. It checks that the operand is an Ok value and produces the underlying integer. If the operand is null instead, it aborts to the enclosing maybe scope.

That mechanism can be implemented in the library, using implicit function types and boundary. We define an error capability that allows to abort with an error of a given type E:

  type CanErr[E] = boundary.Label[Nothing ? E]

The maybe function is then defined as follows:

  inline def maybe[T, E](inline body: CanErr[E] ?=> T): T ? E =
    boundary(Ok(body))

It runs its body while providing an abort capability, and wraps the final result in Ok.

The ? postfix operator is defined as follows:

  extension [T, E](x: T ? E)
    inline def ? (using CanErr[E]): T = x match
      case Ok(y) => y
      case Err(e) => break(Err(e))

Because everything is inline, the existing implementation of boundary will translate maybe blocks to tight code that uses jumps instead of exceptions for aborting.

If we look at the implied typing rules for maybe and ?, we notice a pleasing duality:

          t: R ? E
    ----------------------
     t?: CanErr[E] ?=> R

      t: CanErr[E] ?=> R
    ----------------------
        maybe(t): R ? E

So, in terms of types, maybe and ? are duals of each other. maybe maps a body with implicit function type to a result, whereas ? maps that result to an implicit function type.

Comparison with Other Languages

  • Many languages use the syntax T? for essentially T | Null. I don’t know of a language that makes this type parametric.
  • I also don’t know of a language that lets one treat T? as an instance of a result type T ? E.
  • The postfix ? operator for expressions looks like the one in Rust, but is more general. Rust always aborts to the enclosing function. The scheme presented here introduces maybe as an abort scope, and therefore allows multiple such scopes per function, as well as aborting from nested closures.
23 Likes
  1. Should Valid and Fail be AnyVal or it doesn’t matter in this case?
  2. How maybe types handle equality in general and specifically under strict equality?

Another comment, since Ok and Err are terms that likely to be in scope for various codebases, what about Ok! and Err! instead?

The encoding scheme only works if the internal representations are boxed. Otherwise you can’t decipher nested results. So they have to in practice not be AnyVal except possibly for special cases. (Valhalla may eventually change this.)

I’ve used a similar but not identical scheme for years.

2 Likes

I imagine these will be placed in Predef. This will cause two issues:

  1. Due to erasure, it will collide with existing map and flatMap methods that the user may define. So if introduced in Predef, these will be need @targetType("scalaPredefMap"), @targetType("scalaPredefFlatMap"), etc.
  2. Even if 1 is fixed, there is still the issue where extension methods with the same name do not work well due to shadowing. Relaxed extension methods (SIP 54) are not relaxed enough
1 Like

I haven’t fully understood the design and implementation, but I fully support the motivation.

Option was a great idea in 2010, but it is so clunky and annoying to use that it makes Scala’s optional value ergonomics worse than modern languages like Kotlin or Swift with explicit support for nullable types. In this area, it is now Scala that is clunky and old fashioned

The fact that Options can be properly parametric and nullable types cannot isn’t a good reason to use them everywhere; probably 99% of options out in the wild are not parametric, so it makes zero sense that those 99% have to pay the syntactic/semantic overhead for parametricity they do not use.

Hopefully this proposal and discussion leads to a better path forward for optional value ergonomics in Scala

3 Likes

T ? Nothing =:= T ?

That is natural, but unfortunately the Nothing branch has to be inhabited as a placeholder, which gives all sorts trouble. For instance, if you match on Err (which should be there), what is its value? Uh-oh.

The natural choices are therefore singletons, accepting that T? is not T?<<infer type when none is given>>. The two most obvious candidate singletons are null and Unit. For pattern matching and having something non-explosive, Unit is safer. It’s not the only possible choice, but it’s a reasonable one I think.

Sorry, I was not clear, I meant to ask if T ? Nothing =:= T not T?

It would be nice if there was a way to unify these Maybe types with Flexible types, i.e. to find a way to use Maybe as a replacement for Flexible.

At present I don’t see an immediate way to do that. The two agree in that T | Null <: T? <: T | Null for both. A crucial difference is that for Flexible types, Flexible(T) <: T . We explicitly do want that for Flexible types (for Java interop) but if I understand correctly, we explicitly do not want that for Maybe types, for safety. We want to force people to deconstruct Maybe types explicitly with a pattern match or the maybe operator.

Still, it would be nice if we could find some way to combine the two concepts into one.

Will we have T? =:= T?? ?

The erasure will presumably also need to be Object when T is a primitive type (i.e. we’ll box). Primitive types have caused us all sorts of tricky corner cases in explicit nulls already (e.g. when you substitute Int for T in T|Null).

Is it possible to write a Monad typeclass instance for T ? E if the error goes on the right side?

I envisage another scheme: Internally, T ? E is represented as a class instance Maybe[T, E], where Maybe is a class in the scala.compiletime package (it’s erased, so not visible at runtime).

map, flatMap and withFilter can then be written as extension methods in the companion object of Maybe.

That’s correct for T disjoint from Null,

  • T <: T ? Nothing holds by widening rule, and
  • for T disjoint from Null we have T ? Nothing <: T | Nothing = T.

I doubt that’s possible. The essential point of flexible types seems to be that they are an (unsound) loophole.

No, that would violate parametricity.

Correct. I should change the wording to say The erasure of T? is the erasure of T if T is a reference type that is disjoint from Null , and Object otherwise.

I don’t see why not. We don’t have curried type parameters in Scala, so the parameter order of Either was just in imitation of Haskell. There’s no inherent reason for that order in Scala.

2 Likes

This is a very nice proposal. It looks like a definite answer to the tension between convenience and safety. Whether it meets the bar for interoperability remains to be seen, but I think we can only learn that through experimentation.

I have some suggestions for improvements.

Subtyping and transitivity

Reminder: A ⋔ B means A is “provably disjoint” from B (from the match types spec)

If I have S ⋔ Null and S <: T, I can transitively assign an S to a T? even if T is not disjoint from Null. Indeed we have S <: S? and S? <: T?. However, the proposed typing rules do not support S <: T?, which is awkward. While the Scala type system is not fully transitive, we should avoid more transitivity breaches if we can. Likewise, we can transitively assign an S? to a T | Null by going through an S | Null, but the typing rules do not support that. I propose to refine the second and third typing rules to:

  • S <: T? for all types S and T such that S ⋔ Null and S <: T.
  • S? <: T | Null for all types S and T with the same conditions.

AFAICT, these changes will recover transitivity in this context.

Erasure

As proposed, erasure of T? is ill-defined for Ts that are AnyVals, especially for primitive types. I think the rule should be:

  • erasure(T?) = erasure(T | Null) if T ⋔ Null
  • erasure(T?) = Object otherwise

This would be well-defined, and additionally respect the desirable property that erasure(S) <: erasure(T) when S <: T.

(Note that erasure(Int | Null) is unfortunately Object, not Integer, but that ship has sailed.)

Unit, aka JS’ undefined

IMO, it would be a missed opportunity if we didn’t also address the fact that JavaScript often uses undefined as an absent value (and sometimes null too, unfortunately). In Scala.js, undefined is () (Unit). While the short syntax T? should stand for the null-based optional value, I believe we can address the undefined case with T ? Unit. Here are proposed changes to make this a reality.

T? stands for T ? Null, not T ? Unit

opaque type T ? E = T | Valid | Null | Unit | Fail[E]

so that, at run-time, we can store a () in a T ? E.

Subtyping rules are adapted to handle the Unit cases:

  • Unit <: T ? Unit for all T
  • S <: T ? Unit if S ⋔ Unit and S <: T
  • S? <: T | Unit if S ⋔ Unit and S <: T

Then we have the following changes:

  • Err is changed as follows:
    • Err(x)x if x == null or x == ()
    • Err(x)Fail(x) otherwise (as before)
  • The Err extractor is, for x match { case Err(y) => }:
    • y = z if x == Fail(z)
    • y = x if x == null or x == ()
    • does not match otherwise
  • Ok is changed as follows:
    • Ok(x)Valid(x) if x == null or x == () or x is a ValidorFail` instance
    • Ok(x)x otherwise (as before)
  • The Ok extractor is, for x match { case Ok(y) => }:
    • y = z if x == Valid(z)
    • does not match if x == null or x == () or x is a Fail instance
    • y = x otherwise

It may look like handling () will make this less efficient. However, in most cases for a T ? E we know that T ⋔ Unit and/or E ⋔ Unit anyway, which allows to rule out the () comparisons at compile-time. (and likewise for nulls when when we know E ⋔ Null, which would happen for T ? Unit).

The Ok extractor can be optimized for the common case where a) T ⋔ (Null | Unit) and b) erasure(T) != Object as:

  • y = x if x instanceof erasure(T)
  • does not match otherwise

Erasure follows the same pattern as for Null:

  • erasure(T ? E) = erasure(T | E) if E <: Null | Unit and T ⋔ E
  • erasure(T ? E) = Object otherwise.

Could we generalize the scheme to all Es? (spoiler: I don’t think so)

Given the handling of Null and Unit, could we generalize it to all Es? Could we store T ? E as T | E when T ⋔ E.

Typing rules-wise, it would look like:

  • E <: T ? E for all types E and T
  • S <: T ? E if S ⋔ E and S <: T
  • S ? E <: T | E if S ⋔ E and S <: T
  • S ? D <: T <: E if S <: T and D <: E

And the erasure would be erasure(T ? E) = erasure(T | E) if T ⋔ E, otherwise Object.

Unfortunately I don’t think this works. The proposal’s scheme relies on a unique specification for Ok and Err, that is independent of T and E. We can rule out some tests as optimizations, but only if semantically unobservable. For arbitrary T ? E to be stored as T | E, we would need the semantics of Ok and Err to be dependent on E, lest we wrap every valid value in Valid, defeating the whole purpose.

So no, we have to pick a very limited set of values that we can consider “errors”, and always wrap in Valid values that would be type-tested as such. Some sensible choices for that set of error types :

  • ℰ = Null (the original proposal)
  • ℰ = Null | Unit (what I suggest)
  • ℰ = Null | Unit | Throwable

With each added case in , we need more type tests in Ok and Err for unrestricted Ts. Elements of that set should pay for themselves. As the interoperability champion, my obvious rule would be that should be as large as necessary to handle interop scenarios, but no larger. ℰ = Null | Unit fulfills that definition for all platforms that we target.

4 Likes

Because as far as I know, partial unification is still right-biased. When you call a function with an F[_] type parameter, and it’s supposed to infer that type parameter based on a value of type Either[E, A], it’s going to infer that F[_] is [A] =>> Either[E, A], not [E] =>> Either[E, A]. And if it can’t infer the type parameter correctly, it’s not going to find the typeclass instances either.

This example works (Scastie)

trait Monad[F[_]]:
  def unit[A](a: A): F[A]
  extension [A](fa: F[A]) def flatMap[B](fab: A => F[B]): F[B]

object Stuff:
  opaque type ??[E, A] = Either[E, A]
  given [E] => Monad[[A] =>> ??[E, A]] =
    new:
      def unit[A](a: A): Either[E, A] = Right(a)
      extension [A](fa: Either[E, A]) def flatMap[B](fab: A => Either[E, B]): Either[E, B] = fa.flatMap(fab)

import Stuff.*

extension[A, F[_]: Monad as M](fa: F[A]) def map[B](f: A => B): F[B] = fa.flatMap(a => M.unit(f(a)))

val ok = summon[Monad[[A] =>> ??[Unit, A]]].unit(42)
ok.map(_ * 2)

Flip the type parameters around for ?? and it no longer does (Scastie).

So technically you can write a Monad typeclass instance regardless of the order of the type parameters. It’s just that it won’t do you much good because it’ll break type parameter inference all over the place.

1 Like

Ah, I had not realized that point before. So if we want higher-kinded type inference to work naturally for result types we need to special-case ? so thtr F[_] is [A] =>> A ? E instead of the other way round. I think this would be possible.

1 Like

To me it seems that this feature is relying on a missing feature of opaque type, and maybe we can generalize it so this feature can rely on it.

Currently we can declare:
A. opaque type Id[T] = T
B. opaque type Id[T] <: T = T

For both A and B, we cannot apply val x: Id[Int] = 1 without defining a Conversion[T, Id[T]], and they differ in that for B any Id[Int] can do anything that Int does.

To me it seems there is third option that is missing, tentatively calling it transparent opaque type:
C. transparent opaque type Id[T] = T
For C, val x: Id[Int] = 1 will compile and the other direction will remain opaque.

Now how does it help us with maybe types?
(not fully thought out yet)
What if we define:

transparent opaque type Maybe[T] = T match
  case T => T
  case Null => Null

Match types give us disjointness guarantee for free.

Does this make sense?

1 Like

Why add special cases to the compiler when we can just flip the type parameters?

How is that different from

opaque type Id[Int] >: Int = Int

?

You probably meant to define the cases in the other order:

  case Null => Null
  case T => T

otherwise it always matches case T for all Ts (concrete or abstract). But I don’t see what that gives you except preventing from doing anything with a Maybe[SomeNullableT].

T? actually lets you do something meaningful with nullable Ts. So it’s a lot more widely applicable than a match type that rejects nullable Ts altogether.