Option & Null Safety
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Scala 3.10 (3.10.0, released 2026-10-12). Every example was compiled and run with Scala 3.10.0
through Scala CLI on JDK 26 (checked on 2026-10-15). The Option API has been stable for many releases, so the 3.9 LTS line
(3.9.0, 2026-09-03) and the 3.3 LTS line (3.3.8) give the same results for the first half of the page. The explicit nulls
mode in the second half did change after 3.3, and Scala 3.3 LTS versus 3.9 and 3.10 lists the differences that were
observed by compiling the same programs on all three.
Scala has two ways of saying "there is no value here". The idiomatic one is the library type Option, which makes absence an
ordinary value that the type checker tracks. The inherited one is null, which exists mainly because Scala runs on the JVM
and has to interoperate with Java. This page covers both: Option and everything you do with it, and the opt-in compiler mode
that makes null visible in types as well.
The examples use the trailhead scenario, a toolkit that plans hikes on a network of mountain trails. A trail may or may
not have a mountain hut, and a lookup by name may or may not find a trail. Both are optional values. The Trail class used by
most of the programs is:
case class Trail(name: String, km: Double, hut: Option[String])
The problem with null
In Java, every reference may be null, and nothing in a method’s signature says whether it will return one. The classic
failure is a NullPointerException far away from the place where the null was introduced. Scala 3 keeps null for Java
interoperability (it is the only value of type Null), but idiomatic Scala code does not use it. The Scala 3 Book’s functional
programming chapter avoids null entirely and offers Option as the replacement.
The idea is to move the absence from a runtime surprise to the static type. A method that may not find a value returns
Option[Trail], and the caller cannot reach the Trail without deciding what to do about the empty case. A method that
always finds one returns Trail, and there is nothing to check. The types are the documentation.
Option, Some and None
Option[A] is a container with at most one element. It has exactly two shapes:
-
Some(value)holds a value of typeA. -
Noneis a singleton that holds nothing.
In the standard library Option is a sealed abstract class that is covariant in its type parameter (Option[+A]), Some
is a final case class that extends Option[A], and None is a case object that extends Option[Nothing]. Because
Nothing is a subtype of every type and Option is covariant, the single value None conforms to Option[Trail],
Option[Int] and every other option type. See Variance for why this works,
and Case Classes for what Some gets for free.
Because Option is sealed, the compiler knows these are the only two cases and checks pattern matches over it for
exhaustivity.
There are three ways to create an option:
-
Some(x)wrapsxunconditionally, including whenxisnull(see Pitfalls). -
Option(x)returnsNonewhenxisnullandSome(x)otherwise. It is the right way to wrap a value that comes from Java or from any other source that may producenull. -
Option.empty[A], or plainNone, is the empty option.
The first program defines a lookup that returns an Option and uses it with a pattern match and with map and flatMap.
//> using scala 3.10.0
package trailhead
case class Trail(name: String, km: Double, hut: Option[String])
val trails = List(
Trail("Ridge", 12.5, Some("Refugio Alto")),
Trail("Lake", 7.0, None),
Trail("Pass", 18.2, Some("Hut Sud")))
def findTrail(name: String): Option[Trail] = trails.find(_.name == name)
def describe(o: Option[Trail]): String = o match
case Some(t) => s"${t.name}: ${t.km} km"
case None => "unknown trail"
@main def run(): Unit =
println(describe(findTrail("Lake")))
println(describe(findTrail("Nope")))
println(findTrail("Ridge").map(_.km))
println(findTrail("Ridge").flatMap(_.hut))
println(findTrail("Lake").flatMap(_.hut))
Lake: 7.0 km
unknown trail
Some(12.5)
Some(Refugio Alto)
None
findTrail is the idiomatic replacement for a Java method that would return null for an unknown name. The caller cannot
ignore the result: the only way to get a Trail out of an Option[Trail] is to say what happens when there is none. A
pattern match is the most explicit way to do it, and the compiler would warn if the None case were missing.
map applies a function to the value if there is one, and keeps None as None. flatMap is the same, but for a function
that itself returns an option; it avoids the nested Option[Option[String]] that map would produce. The expression
findTrail("Ridge").flatMap(_.hut) reads "the hut of the Ridge trail, if both the trail and its hut exist", and the third and
fourth lines show it producing None as soon as either step has no value, with no explicit null check anywhere.
Getting the value out
Sooner or later you need a plain value. The options, from safest to least safe:
-
getOrElse(default)returns the value or the default. The default is a by-name parameter, so it is evaluated only when the option is empty. -
fold(ifEmpty)(f)is the same idea with a function for the non-empty case, and both branches produce the same result type. -
orElse(alternative)returns the option itself if it is non-empty and the alternative option otherwise, so it chains fallbacks without leavingOption. -
orNullreturns the value ornull, and is meant for handing a result back to Java code that expectsnull. Using it inside Scala code throws away the safetyOptiongives. -
getreturns the value or throwsNoSuchElementException. Avoid it outside of tests and of code where emptiness has already been excluded; everygetis a deferredNullPointerException.
The next program shows each of them, including the laziness of the default value.
//> using scala 3.10.0
package trailhead
case class Trail(name: String, km: Double, hut: Option[String])
def findTrail(name: String): Option[Trail] =
List(Trail("Ridge", 12.5, Some("Refugio Alto")), Trail("Lake", 7.0, None)).find(_.name == name)
def label(name: String): String =
println(s"computing default for $name")
"camp outside"
@main def run(): Unit =
println(findTrail("Lake").flatMap(_.hut).getOrElse(label("Lake")))
println(findTrail("Ridge").flatMap(_.hut).getOrElse(label("Ridge")))
println(findTrail("Nope").fold("no trail")(t => s"${t.km} km"))
println(findTrail("Ridge").fold("no trail")(t => s"${t.km} km"))
println(findTrail("Nope").orElse(findTrail("Lake")).map(_.name))
println(findTrail("Nope").map(_.name).orNull)
println(scala.util.Try(findTrail("Nope").get))
computing default for Lake
camp outside
Refugio Alto
no trail
12.5 km
Some(Lake)
null
Failure(java.util.NoSuchElementException: None.get)
The first getOrElse printed "computing default for Lake" because the trail has no hut, while the second did not, because
Ridge has one and the by-name default is never evaluated. If the default is expensive to compute, or has a side effect, that
laziness is exactly what you want.
Transforming and testing options
An Option supports the same vocabulary as a collection with at most one element, which is why most code that handles
optional values looks like code that handles lists:
| Method | What it does |
|---|---|
|
Applies |
|
Applies an option-returning |
|
Collapses |
|
Keeps the value only if the predicate holds (or does not hold). |
|
Applies a partial function and keeps the result only where it is defined, combining a pattern match and |
|
True when a value is present and satisfies |
|
True when the option holds exactly |
|
Pairs two options; |
|
Tests; prefer a transformation to a test followed by |
|
Converts to a collection of zero or one element. |
|
Builds an option from a condition, evaluating |
Each of them is exercised in the next program.
//> using scala 3.10.0
package trailhead
case class Trail(name: String, km: Double, hut: Option[String])
def findTrail(name: String): Option[Trail] =
List(Trail("Ridge", 12.5, Some("Refugio Alto")), Trail("Pass", 18.2, Some("Hut Sud"))).find(_.name == name)
@main def run(): Unit =
println(findTrail("Pass").filter(_.km < 10))
println(findTrail("Pass").filterNot(_.km < 10).map(_.name))
println(findTrail("Pass").exists(_.km > 10))
println(findTrail("Nope").exists(_.km > 10))
println(findTrail("Nope").forall(_.km > 10))
println(findTrail("Ridge").map(_.name).contains("Ridge"))
println(findTrail("Pass").collect { case Trail(n, km, Some(h)) if km > 15 => s"$n via $h" })
println(Option.when(findTrail("Pass").isDefined)("trail is known"))
println(Option.unless(findTrail("Pass").isDefined)("trail is unknown"))
println(findTrail("Ridge").zip(findTrail("Pass")).map((a, b) => a.km + b.km))
println(Some(Some(1)).flatten)
println(findTrail("Ridge").toList ++ findTrail("Nope").toList)
println(List(Some(1), None, Some(3)).flatten)
println(List("1", "x", "3").flatMap(_.toIntOption))
println(findTrail("Pass").isEmpty)
None
Some(Pass)
true
false
true
true
Some(Pass via Hut Sud)
Some(trail is known)
None
Some(30.7)
Some(1)
List(Trail(Ridge,12.5,Some(Refugio Alto)))
List(1, 3)
List(1, 3)
false
forall returning true for None (the fifth line) surprises people the first time. It follows the logic definition: a
universally quantified statement over an empty set holds. If you mean "present and satisfies", use exists.
Two of these deserve a closer look because they connect Option to the collections. toList turns an option into a
collection, so findTrail("Ridge").toList ++ findTrail("Nope").toList concatenates the values that exist. The reverse
direction is flatMap or flatten on a collection of options: List(Some(1), None, Some(3)).flatten drops the None
entries, and List("1", "x", "3").flatMap(_.toIntOption) parses all the strings and keeps the ones that were valid numbers.
This works because the collection’s flatMap accepts any function that returns an IterableOnce, and Option is one. For the
collection side see Collection Operations.
Options in for comprehensions
A for comprehension over options stops at the first None. The compiler rewrites it into flatMap and map calls, so it
needs nothing beyond the methods listed above. Each generator x <- opt unwraps one value; the comprehension as a whole
yields an Option.
//> using scala 3.10.0
package trailhead
case class Trail(name: String, km: Double, hut: Option[String])
def findTrail(name: String): Option[Trail] =
List(Trail("Ridge", 12.5, Some("Refugio Alto")), Trail("Lake", 7.0, None)).find(_.name == name)
def parseKm(s: String): Option[Double] = s.toDoubleOption
@main def run(): Unit =
val plan = for
a <- findTrail("Ridge")
b <- findTrail("Lake")
hut <- a.hut
extra <- parseKm("2.5")
yield s"${a.name}+${b.name} via $hut, extra $extra"
println(plan)
val missing = for
a <- findTrail("Ridge")
hut <- findTrail("Lake").flatMap(_.hut)
yield (a.name, hut)
println(missing)
for
t <- findTrail("Ridge")
h <- t.hut
do println(s"hut: $h")
val guarded = for
t <- findTrail("Ridge")
if t.km > 20
yield t.name
println(guarded)
Some(Ridge+Lake via Refugio Alto, extra 2.5)
None
hut: Refugio Alto
None
The first comprehension succeeds only because all four generators produce a value. The second one stops at the hut step
because the Lake trail has no hut, and the whole expression is None. The for ... do form (the third block) runs its
body only when every generator has a value, which makes it the safe version of if (x != null && y != null). The if guard
in the last block calls withFilter, and Option provides it, so a guard turns a Some into None when the condition fails.
The generator and guard rules, pattern generators and the case form are explained in
For Comprehensions.
One limit worth knowing now: a comprehension mixes only one kind of container. You cannot put an Option generator and a
List generator in the same for without converting one of them. Section Pitfalls shows the error.
Option and Java: null and java.util.Optional
Values that come from Java can be null, and Option.apply is the boundary conversion. Wrap every Java return value that
may be null the moment it enters Scala code, and keep Option from then on.
Scala also interoperates with java.util.Optional. The conversions live in scala.jdk.OptionConverters: toScala turns an
Optional into an Option, and toJava goes the other way. They are extension methods, so the import is required.
//> using scala 3.10.0
package trailhead
import scala.jdk.OptionConverters.*
@main def run(): Unit =
println(Option(System.getProperty("no.such.property")))
println(Option(java.util.Map.of("a", 1).get("b")))
println(Option(java.util.Map.of("a", 1).get("a")))
val fromJava: java.util.Optional[String] = java.util.Optional.of("Ridge")
println(fromJava.toScala)
println(Option("Lake").toJava)
println(java.util.Optional.empty[String]().toScala)
None
None
Some(1)
Some(Ridge)
Optional[Lake]
None
Source: Scala API: OptionConverters
Without the import the extension methods are not found:
//> using scala 3.10.0
package trailhead
@main def run(): Unit =
val fromJava: java.util.Optional[String] = java.util.Optional.of("Ridge")
println(fromJava.toScala)
-- [E008] Not Found Error: ./op_java_noimport.scala:6:19
6 | println(fromJava.toScala)
| ^^^^^^^^^^^^^^^^
| value toScala is not a member of java.util.Optional[String]
1 error found
Compilation failed
Source: Scala API: OptionConverters
For Java’s own Optional API see Java Optional. It is closer to Scala’s
Option than it looks: map, flatMap, filter, orElse and isPresent correspond to map, flatMap, filter,
getOrElse and isDefined. A difference worth noting is that Optional is intended mainly for return types and cannot
hold null as a value, while Some(null) is a perfectly valid Scala value.
Option in method signatures
-
Use
Option[A]as a return type for a lookup that may find nothing. This is its main purpose. -
Use
Option[A]as a field type for a value that may be absent, ashut: Option[String]does above. -
Avoid
Option[A]as a parameter type, because every caller has to wrap its argument. If a value is optional for the caller, overload the method or give the parameter a default value (see Methods & Parameters). -
If you need to say why a value is missing,
Optionis too small: it has a single empty value. UseEitherorTry, which carry the reason (see Either & Try).
Pitfalls
Some(null) is a Some. Some.apply does not look at its argument, so Some(null) is a non-empty option holding null,
and so is Some(x).map(_ => null). The null then appears when you use the value. Use Option(x) at every boundary where
null is possible.
//> using scala 3.10.0
package trailhead
@main def run(): Unit =
println(Some(1).map(_ => null))
println(Some("x").map(_ => null: String).getOrElse("fallback"))
println(Some("x").flatMap(_ => Option(null: String)).getOrElse("fallback"))
println(Option(null: String))
println(Option("text"))
println(Some(null))
println(Some(3).size)
println(Some(3).iterator.toList)
println(List(1, 2, 3).flatMap(i => if i > 1 then Some(i) else None))
Some(null)
null
fallback
None
Some(text)
Some(null)
1
List(3)
List(2, 3)
Source: Scala API: Option
The second line printed null, not fallback, because map wrapped the null in a Some, and getOrElse is only
consulted for None. The third line used flatMap with Option(...) and therefore reached the fallback. The last lines
show that Option is a collection of zero or one element: it has a size, an iterator, and a List.flatMap accepts it.
The same mistake produces a NullPointerException when the value is used:
//> using scala 3.10.0
package trailhead
@main def run(): Unit =
val missing: String = null
println(Option(missing).map(_.length))
println(Some(missing).map(_.length))
None
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "_$2" is null
at trailhead.op_pitfall_nullable$package$.run$$anonfun$2(op_pitfall_nullable.scala:7)
at scala.Option.map(Option.scala:283)
at trailhead.op_pitfall_nullable$package$.run(op_pitfall_nullable.scala:7)
at trailhead.run.main(op_pitfall_nullable.scala:4)
Source: Scala API: Option
An Option and a List do not mix in a for or in flatMap on the option. Option.flatMap needs a function that
returns an Option:
//> using scala 3.10.0
package trailhead
@main def run(): Unit =
println(Some(2).flatMap(x => List(x)))
-- [E007] Type Mismatch Error: ./op_pitfall_flatmap.scala:5:35
5 | println(Some(2).flatMap(x => List(x)))
| ^^^^^^^
| Found: List[Int]
| Required: Option[Any]
|
| longer explanation available when compiling with `-explain`
1 error found
Compilation failed
Source: Scala API: Option
Convert one side first (opt.toList, or list.headOption) or start the comprehension from the collection, whose
flatMap accepts an option.
Do not call get, and do not compare to None with == to guard a get. If you wrote if opt.isDefined then opt.get, you
wanted opt.foreach, opt.map or opt.fold. The compiler cannot tie the test and the get together, so a later edit can
separate them silently.
Pattern matching on the content, not the wrapper. A pattern such as case Some(Trail(n, _, Some(h))) matches the option
and the trail and the nested option in one step. Prefer that to a match that unwraps one layer at a time.
Explicit nulls: making null part of the type
Option makes absence explicit for the code you write, but it does nothing for null coming from Java, and nothing stops
a val hut: String = null in Scala code. Scala 3 has an opt-in mode that closes the gap by changing the type system:
explicit nulls.
Explicit nulls is enabled with the compiler option -Yexplicit-nulls. In the Scala 3 reference the feature is listed under
experimental definitions, and the -Y prefix marks an advanced option, so treat it as a feature you opt into knowingly,
not as a default of the language. It is available on 3.3 LTS, 3.9 LTS and 3.10; the rules for Java types differ between 3.3
and later versions (see Scala 3.3 LTS versus 3.9 and 3.10).
In Scala CLI the option is a directive in the file (//> using option -Yexplicit-nulls). In sbt it goes into the compiler
options of the project:
scalacOptions += "-Yexplicit-nulls"
The new type hierarchy
Without the option, Null is a subtype of every reference type, so null can be assigned to a String, a Trail, or
anything else that extends AnyRef. With the option, Null is no longer a subtype of the reference types: it becomes a
regular class under Any. Reference types are non-nullable, and a nullable type is written as the union T | Null. See
Type Hierarchy & Matchable for the hierarchy and
Union & Intersection Types for unions in general. After
erasure Null is still a subtype of every reference type again, as the JVM demands, so the change exists only at compile
time.
Assigning null to a non-nullable type is now an error:
//> using scala 3.10.0
//> using option -Yexplicit-nulls
package trailhead
@main def run(): Unit =
val hut: String = null
println(hut)
-- [E007] Type Mismatch Error: ./en_assign.scala:6:20
6 | val hut: String = null
| ^^^^
|Found: Null
|Required: String
|Note that implicit conversions were not tried because the result of an implicit conversion
|must be more specific than String
|
| longer explanation available when compiling with `-explain`
1 error found
Compilation failed
A value of type String | Null can be null, so you cannot select a member on it without dealing with the null first:
//> using scala 3.10.0
//> using option -Yexplicit-nulls
package trailhead
def hutOf(name: String): String | Null = if name == "Ridge" then "Refugio Alto" else null
@main def run(): Unit =
val hut = hutOf("Ridge")
println(hut.length)
-- [E008] Not Found Error: ./en_select.scala:9:14
9 | println(hut.length)
| ^^^^^^^^^^
| value length is not a member of String | Null.
| Since explicit-nulls is enabled, the selection is rejected because
| String | Null could be null at runtime.
| If you want to select length without checking for a null value,
| insert a .nn before .length or import scala.language.unsafeNulls.
1 error found
Compilation failed
The error message names the two ways out that the language gives you directly, .nn and unsafeNulls, and the rest of this
section covers them and the checks that make them unnecessary.
Flow typing
The compiler tracks null checks. After if x != null, the compiler knows that x is a String in the then branch, and
in the else branch of if x == null. The rule works with &&, || and !, in the condition itself (so
x != null && x.length > 5 is accepted), after assert(x != null), and in match expressions with a case null. It also
works for local `var`s that are not captured and modified by a closure.
//> using scala 3.10.0
//> using option -Yexplicit-nulls
package trailhead
def hutOf(name: String): String | Null = if name == "Ridge" then "Refugio Alto" else null
@main def run(): Unit =
val hut = hutOf("Ridge")
if hut != null then println(hut.length)
if hut != null && hut.length > 5 then println("big hut")
val none = hutOf("Lake")
println(none == null)
println(none match
case null => "no hut"
case h => h.toUpperCase)
var current: String | Null = hutOf("Ridge")
if current != null then
println(current.length)
current = null
println(current == null)
12
big hut
true
no hut
12
true
Assigning null to current inside the if made the compiler forget the non-null fact, which is why the program does not
use current.length afterwards. Flow typing does not follow aliases (s != null && s == s2 tells the compiler nothing about
s2), does not track facts that have nothing to do with null, and does not apply to mutable fields.
For a mutable field the compiler refuses to assume anything, because another thread, or a reentrant call, could assign null
between the check and the use:
//> using scala 3.10.0
//> using option -Yexplicit-nulls
package trailhead
class Cache:
private var hut: String | Null = null
def length: Int =
if hut == null then hut = "Refugio Alto"
hut.length
@main def run(): Unit =
println(Cache().length)
-- [E008] Not Found Error: ./en_stable_plain.scala:10:8
10 | hut.length
| ^^^^^^^^^^
| value length is not a member of String | Null.
| Since explicit-nulls is enabled, the selection is rejected because
| String | Null could be null at runtime.
| If you want to select length without checking for a null value,
| insert a .nn before .length or import scala.language.unsafeNulls.
1 error found
Compilation failed
The annotation scala.annotation.stableNull opts a var field into flow typing when the class guarantees that nobody else
changes it concurrently. It is the field-level counterpart of the local var rule. It was added after 3.9: the same program
fails on 3.9.0 with "Not found: type stableNull", so it needs 3.10 or later.
//> using scala 3.10.0
//> using option -Yexplicit-nulls
package trailhead
import scala.annotation.stableNull
class Cache:
@stableNull private var hut: String | Null = null
def length: Int =
if hut == null then hut = "Refugio Alto"
hut.length
@main def run(): Unit =
println(Cache().length)
12
The reference warns that @stableNull can break null safety, because nothing enforces the promise. Use it for private state
inside one class, such as a lazily initialized value.
.nn and unsafeNulls
When you know a nullable value is not null and the compiler cannot prove it, .nn ("not null") removes Null from the
type. It is an extension method that asserts and casts, so it throws NullPointerException if the value is null. Option
is still the safer way to get from nullable to "present or not": Option(x) accepts a T | Null.
//> using scala 3.10.0
//> using option -Yexplicit-nulls
package trailhead
def hutOf(name: String): String | Null = if name == "Ridge" then "Refugio Alto" else null
@main def run(): Unit =
println(hutOf("Ridge").nn.length)
println(scala.util.Try(hutOf("Lake").nn.length))
println(Option(hutOf("Lake")).getOrElse("no hut"))
println(List(hutOf("Ridge"), hutOf("Lake")).flatMap(Option(_)))
12
Failure(java.lang.NullPointerException: tried to cast away nullability, but value is null)
no hut
List(Refugio Alto)
The compiler warns about a .nn that does nothing (error code E216, available on 3.9.0 and later but not on 3.3.8):
//> using scala 3.10.0
//> using option -Yexplicit-nulls
package trailhead
@main def run(): Unit =
val hut: String = "Refugio Alto"
println(hut.nn)
-- [E216] Syntax Warning: ./en_nn_unneeded.scala:7:14
7 | println(hut.nn)
| ^^^^^^
| Unnecessary .nn: qualifier is already not null
1 warning found
Refugio Alto
A whole file or block can be switched back to the old behaviour with import scala.language.unsafeNulls. Inside the scope
a T | Null can be used as a T, which makes it a migration tool: the Scala 3 reference recommends enabling
-language:unsafeNulls together with -Yexplicit-nulls in a big project and then removing it file by file.
//> using scala 3.10.0
//> using option -Yexplicit-nulls
package trailhead
import scala.language.unsafeNulls
def hutOf(name: String): String | Null = if name == "Ridge" then "Refugio Alto" else null
@main def run(): Unit =
println(hutOf("Ridge").length)
val hut: String = hutOf("Lake")
println(hut)
12
null
The val hut: String = hutOf("Lake") line printed null. Inside an unsafe scope the guarantee is gone, so use it for code you
are migrating, not as a final style.
Java interoperability and flexible types
Every reference type that comes out of Java could be null. Making all of them T | Null would make Java libraries painful
to use, so the compiler gives them a special type: a flexible type, written T? in the reference and not writable in source.
A flexible type T? sits between its two ends: T | Null <: T? <: T. It can be used as a T and as a T | Null, and
member selection on it is allowed. The price is the same as in Java: a NullPointerException if the value is null. The idea
is the same as Kotlin’s platform types (see Kotlin Null Safety).
//> using scala 3.10.0
//> using option -Yexplicit-nulls
package trailhead
@main def run(): Unit =
val property = System.getProperty("no.such.property")
val asString: String = property
val asNullable: String | Null = property
println(asNullable == null)
println(asString.length)
true
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "asString" is null
at trailhead.en_flexible$package$.run(en_flexible.scala:10)
at trailhead.run.main(en_flexible.scala:5)
System.getProperty returns a flexible String?. It was assigned to a String and to a String | Null without complaint,
the null check worked, and only the last line, which treats the value as non-null, failed at run time. Wrap Java results in
Option(...) at the boundary, exactly as in non-explicit-nulls code, and the flexible type disappears.
The compiler applies simple rules when it loads a Java class:
-
Reference types in fields, parameters and return types become flexible; primitive types are left alone.
-
A type parameter is nullable, so
T get()becomesdef get(): T?. -
Java constants (
final String NAME = "name") are not nullified, because they are known not to benull. -
A member annotated with a recognized not-null annotation (for example
org.jetbrains.annotations.NotNull, or Lombok’sNonNull) is not nullified. -
When a Java method is overridden in Scala, the parameter and result types can each be nullable or not, so
def f(x: String): Stringanddef f(x: String | Null): String | Nullboth overrideString f(String x).
An example with a few Java collection types shows the flexible type of get on a java.util.List and the nullable value from a
HashMap:
//> using scala 3.10.0
//> using option -Yexplicit-nulls
package trailhead
@main def run(): Unit =
val huts = java.util.List.of("Alto", "Sud")
println(huts.get(0).length)
val prices = new java.util.HashMap[String, Int]()
prices.put("Alto", 40)
val missing: Int | Null = prices.get("Sud")
println(missing)
println(Option(prices.get("Alto")).map(_ + 5))
4
null
Some(45)
The reference describes -Yno-flexible-types as the way to turn flexible types off, so the ordinary T | Null is used. On
3.10 that gives the strict behaviour:
//> using scala 3.10.0
//> using option -Yexplicit-nulls
//> using option -Yno-flexible-types
package trailhead
@main def run(): Unit =
val property: String = System.getProperty("no.such.property")
println(property)
-- [E007] Type Mismatch Error: ./en_noflex.scala:7:43
7 | val property: String = System.getProperty("no.such.property")
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| Found: String | Null
| Required: String
|
| longer explanation available when compiling with `-explain`
1 error found
Compilation failed
Unsoundness, and safe initialization
Explicit nulls is not fully sound. A field that has not been initialized yet is null at run time even when its declared type
is non-nullable, so a class that reads one of its own fields too early can still produce a null in a String. The compiler
can detect many cases of this with the option -Wsafe-init, described in
Scala 3 Reference: Safe Initialization.
//> using scala 3.10.0
//> using option -Yexplicit-nulls
//> using option -Wsafe-init
package trailhead
class Hut:
val name: String = describe(name)
def describe(s: String): String = s
@main def run(): Unit =
println(Hut().name)
-- Warning: ./en_init.scala:7:6
7 | val name: String = describe(name)
| ^
| Access non-initialized value name. Calling trace:
| ├── class Hut: [ en_init.scala:6 ]
| │ ^
| └── val name: String = describe(name) [ en_init.scala:7 ]
| ^^^^
1 warning found
null
The warning appeared, the program still ran, and printed null for a String. Treat the warning as an error in code that
relies on explicit nulls. The Null guarantee also stops at the boundary of code that was compiled without the option: Scala
libraries compiled without -Yexplicit-nulls keep their old types, which is compatible but unsound.
Option versus explicit nulls
The two mechanisms address the same problem from different angles.
| Aspect | Option[A] |
A | Null (explicit nulls) |
|---|---|---|
Where |
Standard library, always available. |
Compiler mode, opt-in per project. |
Representation |
An object ( |
The value or |
Composition |
|
Null checks, flow typing, |
Java boundary |
|
Flexible types at the boundary; |
Typical use |
Public APIs and domain models. |
Tight loops and interop-heavy code, where the allocation matters. |
Use Option by default. Switch on explicit nulls when a lot of your code calls into Java and you want the compiler to find
the unchecked results. The two work together: Option(x) accepts a T | Null, and a library can expose Option while
using nullable types internally.
Comparison with Java and Kotlin
-
Java has
java.util.Optional, intended for return values, and annotations (@Nullable,@NonNull) enforced by external tools. The language itself has no null safety. -
Kotlin has null safety built into the language:
String?is a nullable type, with the safe call?., the Elvis operator?:and smart casts. Scala’sString | Nullwith flow typing is the explicit-nulls equivalent ofString?plus smart casts, but without?.and?:; you compose withOption,ifandmatchinstead. See Kotlin Null Safety and Java Optional. -
Scala’s
Optionis closest to Kotlin’s nullable type used with?.let { }, and to Java’sOptional. The difference with Kotlin is that in Scala the normal typeStringcan still benullunless explicit nulls is enabled, while in Kotlin it never is. -
In Scala 2
Optionandnullbehave the same; explicit nulls exists only in Scala 3.
Where the books are outdated
-
Scala 2 books describe
nullas an unavoidable part of the language. In Scala 3,-Yexplicit-nullsmakes it a type error, at the cost of an experimental option and of the Java-boundary rules described above. Books that predate Scala 3 do not mention flow typing onnull,.nn,unsafeNulls, flexible types or@stableNull. -
The
-Xcheck-nulland "non-null types" discussions found in Scala 2 material do not apply to Scala 3; explicit nulls is the Scala 3 mechanism. -
Older material uses
JavaConvertersandscala.collection.JavaConversionsfor Java interop; forOptionalthe modern tool isscala.jdk.OptionConverters, as shown above. -
Code written before Scala 2.13 sometimes used
Option.getto "unwrap" after anisDefinedtest. This still compiles, but the idiomatic forms (map,fold,getOrElse, pattern matching,for) are the same in Scala 2 and Scala 3 and are shorter.
Scala 3.3 LTS versus 3.9 and 3.10
The Option examples in the first half of the page gave identical output on 3.3.8, 3.9.0 and 3.10.0 (except for the line
numbers in a stack trace). The explicit nulls examples differ. This table lists the differences seen when the same programs
were compiled on each version:
| Behaviour | 3.3.8 | 3.9.0 | 3.10.0 |
|---|---|---|---|
|
No, error "value length is not a member of String | Null" |
Yes |
Yes |
Java results are flexible types ( |
No, the same line fails with Found |
Yes |
Yes |
Calling a method on the result of a Java factory ( |
Rejected, because |
Allowed |
Allowed |
|
Option ignored ("bad option") |
Warns |
Warns |
Warning E216 for an unneeded |
Not reported |
Reported |
Reported |
|
Not available |
Not available ("Not found: type stableNull") |
Available |
Practical consequence: on the 3.3 LTS the strict rules force a lot of .nn and Option(...) around Java calls, and match-based
narrowing does not work. If you are adopting explicit nulls, do it on 3.9 or later.
The reference page also shows a head[T](xs: java.util.List[T]): T = xs.get(0) example inside unsafeNulls that is said not to
compile. In the test used for this page it did compile on 3.9.0 and 3.10.0, printing Alto, so add a .nn only if your compiler
asks for it. This is an example of documentation that has fallen a little behind the compiler.