Skip to content

Relax comparison between Null and reference types in explicit nulls #23308

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions compiler/src/dotty/tools/dotc/typer/Synthesizer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -176,19 +176,6 @@ class Synthesizer(typer: Typer)(using @constructorOnly c: Context):
cmpWithBoxed(cls1, cls2)
else if cls2.isPrimitiveValueClass then
cmpWithBoxed(cls2, cls1)
else if ctx.mode.is(Mode.SafeNulls) then
// If explicit nulls is enabled, and unsafeNulls is not enabled,
// we want to disallow comparison between Object and Null.
// If we have to check whether a variable with a non-nullable type has null value
// (for example, a NotNull java method returns null for some reasons),
// we can still cast it to a nullable type then compare its value.
//
// Example:
// val x: String = null.asInstanceOf[String]
// if (x == null) {} // error: x is non-nullable
// if (x.asInstanceOf[String|Null] == null) {} // ok
if cls1 == defn.NullClass || cls2 == defn.NullClass then cls1 == cls2
else cls1 == defn.NothingClass || cls2 == defn.NothingClass
else if cls1 == defn.NullClass then
cls1 == cls2 || cls2.derivesFrom(defn.ObjectClass)
else if cls2 == defn.NullClass then
Expand Down
24 changes: 4 additions & 20 deletions docs/_docs/reference/experimental/explicit-nulls.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,26 +90,10 @@ More details can be found in [safe initialization](../other-new-features/safe-in

## Equality

We don't allow the double-equal (`==` and `!=`) and reference (`eq` and `ne`) comparison between
`AnyRef` and `Null` anymore, since a variable with a non-nullable type cannot have `null` as 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 have to provide a type hint (e.g. `: Any`).

```scala
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
```
We still allow the double-equal (`==` and `!=`), reference (`eq` and `ne`) comparison,
and pattern matching between `Null` and reference types.
Even if a type is non-nullable, we still need to consider the possibility of `null` value
caused by the Java methods or uninitialized values.

## Java Interoperability and Flexible Types

Expand Down
33 changes: 21 additions & 12 deletions tests/explicit-nulls/neg/equal1.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// Test what can be compared for equality against null.
class Foo {

case class VC(x: Int) extends AnyVal

def test =
// Null itself
val x0: Null = null
x0 != x0
Expand All @@ -9,21 +12,21 @@ class Foo {
null == null
null != null

// Non-nullable types: error
// Non-nullable types: OK.
val x1: String = "hello"
x1 != null // error
x1 == null // error
null == x1 // error
null != x1 // error
x1 == x0 // error
x0 != x1 // error
x1.asInstanceOf[String|Null] == null
x1.asInstanceOf[String|Null] == x0
x1 != null
x1 == null
null == x1
null != x1
x1 == x0
x0 != x1
x1.asInstanceOf[String | Null] == null
x1.asInstanceOf[String | Null] == x0
x1.asInstanceOf[Any] == null
x1.asInstanceOf[Any] == x0

// Nullable types: OK
val x2: String|Null = null
val x2: String | Null = null
x2 == null
null == x2
x2 == x0
Expand All @@ -41,4 +44,10 @@ class Foo {
null == false // error
'a' == null // error
null == 'b' // error
}

// Nullable value types: OK.
val x3: Int | Null = null
x3 == null
null == x3
x3 == x0
x3 != x0
13 changes: 6 additions & 7 deletions tests/explicit-nulls/neg/equal2.scala
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
// Test that we can't compare for equality `null` with classes.
// This rule is for both regular classes and value classes.
// Test that we can compare values of regular classes against null,
// but not values of value classes.

class Foo(x: Int)
class Bar(x: Int) extends AnyVal

class Test {
locally {
val foo: Foo = new Foo(15)
foo == null // error: Values of types Null and Foo cannot be compared
null == foo // error
foo != null // error
null != foo // error
foo == null
null == foo
foo != null
null != foo

// To test against null, make the type nullable.
val foo2: Foo | Null = foo
// ok
foo2 == null
Expand Down
8 changes: 3 additions & 5 deletions tests/explicit-nulls/neg/flow-match.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@
object MatchTest {
def f6(s: String | Null): String = s match {
case s2 => s2 // error
case null => "other" // error
case s3 => s3
case s3 => s3 // OK since not null
}

def f7(s: String | Null): String = s match {
case null => "other"
case null => "other" // error
case s3 => s3
case s3 => s3 // OK since not null
}
}
}
22 changes: 9 additions & 13 deletions tests/explicit-nulls/neg/flow-strip-null.scala
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
// Test we are correctly striping nulls from nullable unions.

class Foo {
class Foo:

class B1
class B2
locally {

locally:
val x: (Null | String) | Null | (B1 | (Null | B2)) = ???
if (x != null) {
if x != null then
val _: String | B1 | B2 = x // ok: can remove all nullable unions
}
}

locally {
locally:
val x: (Null | String) & (Null | B1) = ???
if (x != null) {
if x != null then
val _: String & B1 = x // ok: can remove null from embedded intersection
}
}

locally {
locally:
val x: (Null | B1) & B2 = ???
if (x != null) {} // error: the type of x is not a nullable union, so we cannot remove the Null
}
}
if x != null then
val _: B1 & B2 = x // error: the type of x is not a nullable union, so we cannot remove the Null
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ class S {
null == s1
null != s1

s2 == null // error
s2 != null // error
null == s2 // error
null != s2 // error
s2 == null
s2 != null
null == s2
null != s2

s1 == s2
s1 != s2
Expand All @@ -27,21 +27,21 @@ class S {
null != n

s1 == n
s2 == n // error
s2 == n
n != s1
n != s2 // error
n != s2
}

locally {
ss1 == null // error
ss1 != null // error
null == ss1 // error
null != ss1 // error

ss1 == n // error
ss1 != n // error
n == ss1 // error
n != ss1 // error
ss1 == null
ss1 != null
null == ss1
null != ss1

ss1 == n
ss1 != n
n == ss1
n != ss1

ss1 == ss2
ss2 != ss1
Expand Down
38 changes: 0 additions & 38 deletions tests/explicit-nulls/pos/pattern-matching.scala

This file was deleted.

37 changes: 37 additions & 0 deletions tests/explicit-nulls/run/pattern-matching.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
object Test:

def main(args: Array[String]): Unit =

val s: String = null.asInstanceOf[String]

val r1 = s match
case s: String => 100
case _ => 200
assert(r1 == 200)

val r2 = s match
case s: String => 100
case null => 200
assert(r2 == 200)

val r3 = s match
case null => 100
case _ => 200
assert(r3 == 100)

val s2: String | Null = null

val r4 = s2 match
case s2: String => 100
case _ => 200
assert(r4 == 200)

val r5 = s2 match
case s2: String => 100
case null => 200
assert(r5 == 200)

val r6 = s2 match
case null => 200
case s2: String => 100
assert(r6 == 200)
11 changes: 0 additions & 11 deletions tests/explicit-nulls/unsafe-common/unsafe-match-null.scala

This file was deleted.

16 changes: 16 additions & 0 deletions tests/explicit-nulls/warn/flow-match.check
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- [E030] Match case Unreachable Warning: tests/explicit-nulls/warn/flow-match.scala:6:9 -------------------------------
6 | case null => "other" // warn
| ^^^^
| Unreachable case
-- [E030] Match case Unreachable Warning: tests/explicit-nulls/warn/flow-match.scala:7:9 -------------------------------
7 | case s3 => s3 // warn
| ^^
| Unreachable case
-- [E030] Match case Unreachable Warning: tests/explicit-nulls/warn/flow-match.scala:12:9 ------------------------------
12 | case null => "other" // warn
| ^^^^
| Unreachable case
-- [E030] Match case Unreachable Warning: tests/explicit-nulls/warn/flow-match.scala:14:9 ------------------------------
14 | case s4 => s4.nn // warn
| ^^
| Unreachable case
16 changes: 16 additions & 0 deletions tests/explicit-nulls/warn/flow-match.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Test unreachable matches in presence of nulls

object MatchTest2 {
def f6(s: String | Null): String = s match {
case s2 => s2.nn
case null => "other" // warn
case s3 => s3 // warn
}

def f7(s: String | Null): String = s match {
case null => "other"
case null => "other" // warn
case s3: String => s3
case s4 => s4.nn // warn
}
}
6 changes: 3 additions & 3 deletions tests/explicit-nulls/warn/i21577.check
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,18 @@
| ^
| Unreachable case
-- [E029] Pattern Match Exhaustivity Warning: tests/explicit-nulls/warn/i21577.scala:29:27 -----------------------------
29 |def f7(s: String | Null) = s match // warn: not exhuastive
29 |def f7(s: String | Null) = s match // warn: not exhaustive
| ^
| match may not be exhaustive.
|
| It would fail on pattern case: _: Null
|
| longer explanation available when compiling with `-explain`
-- [E029] Pattern Match Exhaustivity Warning: tests/explicit-nulls/warn/i21577.scala:36:33 -----------------------------
36 |def f9(s: String | Int | Null) = s match // warn: not exhuastive
36 |def f9(s: String | Int | Null) = s match // warn: not exhaustive
| ^
| match may not be exhaustive.
|
| It would fail on pattern case: _: Int
|
| longer explanation available when compiling with `-explain`
| longer explanation available when compiling with `-explain`
6 changes: 3 additions & 3 deletions tests/explicit-nulls/warn/i21577.scala
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ def f6(s: String) = s.trim() match
def f61(s: String) = s.trim() match
case _: String =>

def f7(s: String | Null) = s match // warn: not exhuastive
def f7(s: String | Null) = s match // warn: not exhaustive
case _: String =>

def f8(s: String | Null) = s match
case _: String =>
case null =>

def f9(s: String | Int | Null) = s match // warn: not exhuastive
def f9(s: String | Int | Null) = s match // warn: not exhaustive
case _: String =>
case null =>
case null =>
Loading
Loading