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 type A.

  • None is 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) wraps x unconditionally, including when x is null (see Pitfalls).

  • Option(x) returns None when x is null and Some(x) otherwise. It is the right way to wrap a value that comes from Java or from any other source that may produce null.

  • Option.empty[A], or plain None, 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 leaving Option.

  • orNull returns the value or null, and is meant for handing a result back to Java code that expects null. Using it inside Scala code throws away the safety Option gives.

  • get returns the value or throws NoSuchElementException. Avoid it outside of tests and of code where emptiness has already been excluded; every get is a deferred NullPointerException.

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

map(f)

Applies f to the value, if any, and wraps the result.

flatMap(f)

Applies an option-returning f and flattens the result.

flatten

Collapses Option[Option[A]] to Option[A].

filter(p), filterNot(p)

Keeps the value only if the predicate holds (or does not hold).

collect(pf)

Applies a partial function and keeps the result only where it is defined, combining a pattern match and map.

exists(p), forall(p)

True when a value is present and satisfies p; forall is also true for None.

contains(x)

True when the option holds exactly x.

zip(other)

Pairs two options; None if either is empty.

isEmpty, nonEmpty, isDefined

Tests; prefer a transformation to a test followed by get.

toList, iterator

Converts to a collection of zero or one element.

Option.when(cond)(x), Option.unless(cond)(x)

Builds an option from a condition, evaluating x only when it is needed.

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

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

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, as hut: 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, Option is too small: it has a single empty value. Use Either or Try, 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)

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)

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

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() becomes def get(): T?.

  • Java constants (final String NAME = "name") are not nullified, because they are known not to be null.

  • A member annotated with a recognized not-null annotation (for example org.jetbrains.annotations.NotNull, or Lombok’s NonNull) 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): String and def f(x: String | Null): String | Null both override String 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 (Some is allocated, None is a singleton).

The value or null, with no allocation.

Composition

map, flatMap, for, pattern matching.

Null checks, flow typing, .nn; no map.

Java boundary

Option(x).

Flexible types at the boundary; Option(x) still works.

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’s String | Null with flow typing is the explicit-nulls equivalent of String? plus smart casts, but without ?. and ?:; you compose with Option, if and match instead. See Kotlin Null Safety and Java Optional.

  • Scala’s Option is closest to Kotlin’s nullable type used with ?.let { }, and to Java’s Optional. The difference with Kotlin is that in Scala the normal type String can still be null unless explicit nulls is enabled, while in Kotlin it never is.

  • In Scala 2 Option and null behave the same; explicit nulls exists only in Scala 3.

Where the books are outdated

  • Scala 2 books describe null as an unavoidable part of the language. In Scala 3, -Yexplicit-nulls makes 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 on null, .nn, unsafeNulls, flexible types or @stableNull.

  • The -Xcheck-null and "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 JavaConverters and scala.collection.JavaConversions for Java interop; for Optional the modern tool is scala.jdk.OptionConverters, as shown above.

  • Code written before Scala 2.13 sometimes used Option.get to "unwrap" after an isDefined test. 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

case null => ...; case h => h.length narrows h to String

No, error "value length is not a member of String | Null"

Yes

Yes

Java results are flexible types (val s: String = System.getProperty("p") compiles)

No, the same line fails with Found String | Null, Required String

Yes

Yes

Calling a method on the result of a Java factory (java.util.List.of("a").get(0))

Rejected, because of returns java.util.List[String] | Null

Allowed

Allowed

-Wsafe-init warning

Option ignored ("bad option")

Warns

Warns

Warning E216 for an unneeded .nn

Not reported

Reported

Reported

@stableNull

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.