Explicit Nulls

Explicit nulls is an opt-in feature that modifies the Scala type system, which makes reference types (anything that extends AnyRef) non-nullable.

This means the following code will no longer typecheck:

val x: String = null // error: found `Null`,  but required `String`

Instead, to mark a type as nullable we use a type union

val x: String|Null = null // ok

Explicit nulls are enabled via a -Yexplicit-nulls flag, so they're an opt-in feature.

Read on for details.

New Type Hierarchy

When explicit nulls are enabled, the type hierarchy changes so that Null is subtype only of Any, as opposed to every reference type.

This is the new type hierarchy:

After erasure, Null remains a subtype of all reference types (as forced by the JVM).

Unsoundness

The new type system is unsound with respect to null. This means there are still instances where an expressions has a non-nullable type like String, but its value is null.

The unsoundness happens because uninitialized fields in a class start out as null:

class C {
  val f: String = foo(f)
  def foo(f2: String): String = if (f2 == null) "field is null" else f2
}
val c = new C()
// c.f == "field is null"

Enforcing sound initialization is a non-goal of this proposal. However, once we have a type system where nullability is explicit, we can use a sound initialization scheme like the one proposed by @liufengyun and @biboudis in https://github.com/lampepfl/dotty/pull/4543 to eliminate this particular source of unsoundness.

Equality

We don't allow the double-equal (== and !=) and reference (eq and ne) comparison between AnyRef and Null anymore, since a variable with non-nullable type shouldn't have null value. null can only be compared with Null, nullable union (T | Null), or Any type.

For some reason, if we really want to compare null with non-null values, we can use cast.

val x: String = ???
val y: String | Null = ???

x == null       // error: Values of types String and Null cannot be compared with == or !=
x eq null       // error
"hello" == null // error

y == null       // ok
y == x          // ok

(x: String | Null) == null  // ok
(x: Any) == null            // ok

Working with Null

To make working with nullable values easier, we propose adding a few utilities to the standard library. So far, we have found the following useful:

Java Interop

The compiler can load Java classes in two ways: from source or from bytecode. In either case, when a Java class is loaded, we "patch" the type of its members to reflect that Java types remain implicitly nullable.

Specifically, we patch * the type of fields * the argument type and return type of methods

UncheckedNull is an alias for Null with magic properties (see below). We illustrate the rules with following examples:

UncheckedNull

To enable method chaining on Java-returned values, we have the special type alias for Null:

type UncheckedNull = Null

UncheckedNull behaves just like Null, except it allows (unsound) member selections:

// Assume someJavaMethod()'s original Java signature is
// String someJavaMethod() {}
val s2: String = someJavaMethod().trim().substring(2).toLowerCase() // unsound

Here, all of trim, substring and toLowerCase return a String|UncheckedNull. The Typer notices the UncheckedNull and allows the member selection to go through. However, if someJavaMethod were to return null, then the first member selection would throw a NPE.

Without UncheckedNull, the chaining becomes too cumbersome

val ret = someJavaMethod()
val s2 = if (ret != null) {
  val tmp = ret.trim()
  if (tmp != null) {
    val tmp2 = tmp.substring(2)
    if (tmp2 != null) {
      tmp2.toLowerCase()
    }
  }
}
// Additionally, we need to handle the `else` branches.

Flow Typing

We added a simple form of flow-sensitive type inference. The idea is that if p is a stable path or a trackable variable, then we can know that p is non-null if it's compared with the null. This information can then be propagated to the then and else branches of an if-statement (among other places).

Example:

val s: String|Null = ???
if (s != null) {
  // s: String
}
// s: String|Null

assert(x != null)
// s: String

A similar inference can be made for the else case if the test is p == null

if (s == null) {
  // s: String|Null
} else {
  // s: String
}

== and != is considered a comparison for the purposes of the flow inference.

Logical Operators

We also support logical operators (&&, ||, and !):

val s: String|Null = ???
val s2: String|Null = ???
if (s != null && s2 != null) {
  // s: String
  // s2: String
}

if (s == null || s2 == null) {
  // s: String|Null
  // s2: String|Null
} else {
  // s: String
  // s2: String
}

Inside Conditions

We also support type specialization within the condition, taking into account that && and || are short-circuiting:

val s: String|Null = ???

if (s != null && s.length > 0) { // s: String in `s.length > 0`
  // s: String
}

if (s == null || s.length > 0) // s: String in `s.length > 0` {
  // s: String|Null
} else {
  // s: String|Null
}

Match Case

The non-null cases can be detected in match statements.

val s: String|Null = ???

s match {
  case _: String => // s: String
  case _ =>
}

Mutable Variable

We are able to detect the nullability of some local mutable variables. A simple example is:

class C(val x: Int, val next: C|Null)

var xs: C|Null = C(1, C(2, null))
// xs is trackable, since all assignments are in the same mathod
while (xs != null) {
  // xs: C
  val xsx: Int = xs.x
  val xscpy: C = xs
  xs = xscpy // since xscpy is non-null, xs still has type C after this line
  // xs: C
  xs = xs.next // after this assignment, xs can be null again
  // xs: C | Null
}

When dealing with local mutable variables, there are two questions:

  1. Whether to track a local mutable variable during flow typing. We track a local mutable variable iff the variable is not assigned in a closure. For example, in the following code x is assigned to by the closure y, so we do not do flow typing on x.
var x: String|Null = ???
def y = {
  x = null
}
if (x != null) {
  // y can be called here, which break the fact
  val a: String = x // error: x is captured and mutated by the closure, not trackable
}
  1. Whether to generate and use flow typing on a specific use of a local mutable variable. We only want to do flow typing on a use that belongs to the same method as the definition of the local variable. For example, in the following code, even x is not assigned to by a closure, but we can only use flow typing in one of the occurrences (because the other occurrence happens within a nested closure).
var x: String|Null = ???
def y = {
  if (x != null) {
    // not safe to use the fact (x != null) here
    // since y can be executed at the same time as the outer block
    val _: String = x
  }
}
if (x != null) {
  val a: String = x // ok to use the fact here
  x = null
}

See more examples in tests/explicit-nulls/neg/var-ref-in-closure.scala.

Currently, we are unable to track paths with a mutable variable prefix. For example, x.a if x is mutable.

Unsupported Idioms

We don't support:

Binary Compatibility

Our strategy for binary compatibility with Scala binaries that predate explicit nulls and new libraries compiled without -Yexplicit-nulls is to leave the types unchanged and be compatible but unsound.

More details