Using Java from Scala

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) and every example was compiled and run with Scala 3.10.0 through Scala CLI on JDK 26 (checked on 2026-10-15). Scala and Java share one runtime, so a Scala program can create Java objects, call Java methods, extend Java classes and implement Java interfaces with no wrapper layer and no foreign-function glue. What differs is the vocabulary: Java collections are not Scala collections, java.util.Optional is not Option, a Java method may return null, and Java generics know nothing about Scala variance. This page walks through each of those seams. The opposite direction, Java calling Scala, is on Calling Scala from Java. Everything here works the same on the 3.9 LTS line and on 3.3 LTS unless Scala 3.9 LTS versus 3.10 says otherwise; the examples were run on 3.10.0 only, so statements about other versions come from release notes or from the explicitly labelled runs.

The examples use the trailhead scenario of a toolkit that plans hikes. Some of them need Java types, so they come with a handful of small Java source files. For the Java side see Java Collections Framework, Optional and Lambdas and Method References.

Running Java and Scala together

A Scala CLI project may contain .java files next to .scala files, and the Scala compiler reads the Java sources for type information (the Java parser built into the compiler provides it). To also produce bytecode for the Java files, Scala CLI hands the project to its build server (Bloop and Zinc), which compiles Java and Scala in the order they depend on each other. Scala CLI itself prints this warning when the build server is switched off with --server=false:

[warn] With --server=false, .java files are not compiled to .class files.
scalac parses .java sources for type information (cross-compilation), but without the build server (Bloop/Zinc) nothing compiles them to bytecode.
Remove --server=false or compile Java files separately to avoid runtime NoClassDefFoundError.

The examples on this page were run without the build server, so the Java files were compiled once with javac and the output directory was added to the Scala CLI class path with --extra-jar (it accepts a directory of classes):

javac -d out Surface.java Waypoint.java Shape.java Registry.java
scala run --server=false --extra-jar out .

The result is the same as a normal mixed project. In an sbt build the equivalent is the compileOrder setting, whose default Mixed lets Scala sources refer to Java sources and the other way around; see sbt & Build Tools and Scala CLI. The one case not run here is the single-command mixed build through the build server, because the runs on this page deliberately avoided starting it.

Java collections

Scala collections and java.util collections are unrelated types, so a java.util.List[String] cannot be passed where a Scala List[String] is expected. The standard library bridges them with scala.jdk.CollectionConverters, a set of extension methods asScala and asJava. Import it with a wildcard, which brings in the extensions.

//> using scala 3.10.0

import java.util.{ArrayList, HashMap, List as JList, Map as JMap}
import scala.jdk.CollectionConverters.*

def javaTrails(): JList[String] =
  val l = new ArrayList[String]()
  l.add("Ridge"); l.add("Pico"); l.add("Lake")
  l

@main def run(): Unit =
  val jl = javaTrails()
  val sl: List[String] = jl.asScala.toList        // copy to an immutable List
  println(sl)
  val buf = jl.asScala                             // a live view: mutable.Buffer
  println(buf.getClass.getName)
  buf += "Col"
  println(jl)                                      // the Java list sees the change
  val jm = new HashMap[String, Int]()
  jm.put("Ridge", 8); jm.put("Pico", 12)
  val sm = jm.asScala.toMap
  println(sm.toList.sortBy(_._1))
  val back: JMap[String, Int] = Map("a" -> 1, "b" -> 2).asJava
  println(back.getClass.getName)
  try back.put("c", 3) catch case e: UnsupportedOperationException => println("immutable view: " + e.getClass.getSimpleName)
  println(List(1, 2, 3).asJava.getClass.getName)
  val it = jl.iterator().asScala
  println(it.map(_.toUpperCase).mkString(","))
  for t <- jl.asScala do println(t)
List(Ridge, Pico, Lake)
scala.collection.convert.JavaCollectionWrappers$JListWrapper
[Ridge, Pico, Lake, Col]
List((Pico,12), (Ridge,8))
scala.collection.convert.JavaCollectionWrappers$MapWrapper
immutable view: UnsupportedOperationException
scala.collection.convert.JavaCollectionWrappers$SeqWrapper
RIDGE,PICO,LAKE,COL
Ridge
Pico
Lake
Col

The class names printed above explain the semantics. asScala and asJava do not copy: they return a thin wrapper that forwards every call to the original collection. A write through the Scala Buffer showed up in the Java list, and a Scala immutable Map converted with asJava throws UnsupportedOperationException on put, because the wrapper cannot change something that is immutable. If a snapshot is wanted, finish the conversion with toList, toMap, toSet or toVector, which copy into a Scala collection.

The table lists the conversions of CollectionConverters, which is the same set the 2.13 collections guide documents.

Java type Scala type from asScala Back with asJava

java.util.Iterator

Iterator

yes

java.lang.Iterable, java.util.Collection

Iterable

yes (asJavaCollection for a Collection)

java.util.List

mutable.Buffer

yes (mutable.Buffer, Seq, mutable.Seq)

java.util.Set

mutable.Set

yes (Set, mutable.Set)

java.util.Map

mutable.Map

yes (Map, mutable.Map)

java.util.concurrent.ConcurrentMap

concurrent.Map

yes

java.util.Dictionary, java.util.Properties

mutable.Map

asJavaDictionary

java.util.Enumeration

Iterator

asJavaEnumeration

Two practical consequences follow from the wrapper design. First, a Java List is viewed as a mutable Buffer, never as an immutable List, so the type tells you what can be done with it. Second, a Java collection is not a Scala collection, so it cannot be the generator of a for comprehension. The compiler says so, and asScala is the fix (see For Comprehensions):

//> using scala 3.10.0
import java.util.ArrayList

@main def run(): Unit =
  val jl = new ArrayList[String]()
  jl.add("a")
  for s <- jl do println(s)
-- [E008] Not Found Error: F.scala:7:11
7 |  for s <- jl do println(s)
  |           ^^
  |value foreach is not a member of java.util.ArrayList[String] - did you mean jl.forEach?
1 error found

Java collections are usually mutable, and many Java APIs mutate their arguments, so it is common to use the Java API on the Java collection and convert only at the border. The standard Java methods that take lambdas work directly, as the next example shows. Everything runs on JDK 26 and also relies on SAM conversion, which is explained in Functional interfaces and SAM conversion.

//> using scala 3.10.0
import java.util.{ArrayList, Comparator, Collections, HashMap}
import scala.jdk.CollectionConverters.*

@main def run(): Unit =
  val jl = new ArrayList[String]()
  jl.add("pico"); jl.add("ridge"); jl.add("lake")
  val cmp: Comparator[String] = (a, b) => a.length - b.length
  Collections.sort(jl, cmp)
  println(jl)
  jl.sort(Comparator.comparing[String, Integer](_.length).reversed())
  println(jl)
  jl.removeIf(_.startsWith("l"))
  println(jl)
  val m = new HashMap[String, Int]()
  m.merge("a", 1, _ + _)
  m.merge("a", 1, _ + _)
  m.computeIfAbsent("b", _ => 10)
  println(m.asScala.toList.sorted)
  for e <- m.entrySet().asScala.toList.sortBy(_.getKey) do println(s"${e.getKey} -> ${e.getValue}")
  val ints: Array[Int] = Array(3, 1, 2)
  java.util.Arrays.sort(ints)
  println(ints.toList)
[pico, lake, ridge]
[ridge, pico, lake]
[ridge, pico]
List((a,2), (b,10))
a -> 2
b -> 10
List(1, 2, 3)

A Scala Array[Int] is a real JVM int[], so it can be passed to Java methods such as Arrays.sort directly. Compare this with Seq or List, which are Scala types and must be converted.

Streams and Optional

Java 8 added java.util.stream.Stream and java.util.Optional, and the standard library has a converter for each: scala.jdk.StreamConverters and scala.jdk.OptionConverters. The extension toScala turns a Java value into a Scala one and toJava does the reverse. Both live in scala.jdk. They replace the older scala.collection.JavaConverters (still present on 3.10.0 but deprecated) and the implicit scala.collection.JavaConversions (which converted silently and no longer exists on 3.10.0; see Where the books are outdated).

//> using scala 3.10.0

import java.util.Optional
import java.util.stream.{Stream as JStream, IntStream}
import scala.jdk.OptionConverters.*
import scala.jdk.StreamConverters.*
import scala.jdk.CollectionConverters.*

def findTrail(name: String): Optional[String] =
  if name == "Ridge" then Optional.of("Ridge trail") else Optional.empty()

@main def run(): Unit =
  val a: Option[String] = findTrail("Ridge").toScala
  val b: Option[String] = findTrail("Nope").toScala
  println((a, b))
  val c: Optional[Int] = Some(3).toJava
  val d: Optional[Int] = Option.empty[Int].toJava
  println((c, d))
  // Optional of a primitive
  val od = java.util.OptionalDouble.of(2.5)
  println(od.toScala)
  println(Option(2.5).toJavaPrimitive)
  // Streams
  val s: JStream[String] = List("a", "bb", "ccc").asJava.stream()
  println(s.toScala(List).map(_.length))
  println(IntStream.rangeClosed(1, 5).toScala(Vector))
  println(List(1, 2, 3).asJavaSeqStream.map(_ * 2).toArray.toList)
  println(Vector("x", "y").asJavaSeqStream.toScala(List))
  println(Array(1, 2, 3, 4).asJavaSeqStream.sum)
(Some(Ridge trail),None)
(Optional[3],Optional.empty)
Some(2.5)
OptionalDouble[2.5]
List(1, 2, 3)
Vector(1, 2, 3, 4, 5)
List(2, 4, 6)
List(x, y)
10

Notice that toScala on a stream takes the target collection as an argument (toScala(List)), and that asJavaSeqStream on a List[Int] produces a primitive IntStream, so .sum and .toArray are available while .toList is not (the method is toScala(List)). The primitive variants of Optional have toScala too, and toJavaPrimitive makes an OptionalDouble from an Option[Double]. Stream, IntStream, LongStream and DoubleStream are all covered. Scala has no counterpart of a Java Stream pipeline for its own collections; the collection methods themselves (see Collection Operations) and views play that role, so conversion is needed mainly at API boundaries.

Java classes, enums, records and static members

Using a Java class from Scala needs no special syntax: new or the creator-application form Registry(), and member access with a dot. The next Java sources define an enum, a record, a sealed interface of records and a plain class.

// Surface.java
package trailhead;

public enum Surface {
    GRAVEL("gravel", 1.0), ROCK("rock", 1.5), SNOW("snow", 2.0);

    private final String label;
    private final double effort;

    Surface(String label, double effort) { this.label = label; this.effort = effort; }

    public String label() { return label; }
    public double effort() { return effort; }
}

// Waypoint.java
package trailhead;

public record Waypoint(String name, int elevationM) {
    public Waypoint {
        if (elevationM < 0) throw new IllegalArgumentException("negative elevation");
    }
    public static Waypoint summit(String name) { return new Waypoint(name, 3000); }
}

// Shape.java
package trailhead;

public sealed interface Shape permits Shape.Circle, Shape.Rect {
    record Circle(double r) implements Shape {}
    record Rect(double w, double h) implements Shape {}
}

// Registry.java
package trailhead;

import java.util.*;

public class Registry {
    public static final int MAX = 5;
    public static int created = 0;
    private final Map<String, Waypoint> byName = new LinkedHashMap<>();
    public Registry() { created++; }
    public void add(Waypoint w) { byName.put(w.name(), w); }
    public Waypoint find(String name) { return byName.get(name); }          // may return null
    public String note() { return null; }
    public List<Waypoint> all() { return new ArrayList<>(byName.values()); }
    public int size() { return byName.size(); }
    public static String describe(String sep, String... parts) { return String.join(sep, parts); }
    public static <T extends Comparable<T>> T max(List<? extends T> xs) {
        T best = null;
        for (T x : xs) if (best == null || x.compareTo(best) > 0) best = x;
        return best;
    }
}

The Scala side uses all four. A Java enum is an ordinary class with static fields for its constants, values() and valueOf as static methods, and it can be used in a match (which is checked for exhaustivity, as the next example shows). A Java record exposes its components as accessor methods named after the components (w.name, not w.getName) and on Scala 3.10 it can be matched with a constructor pattern, because the compiler reads the record components from the class file. Record patterns are new in 3.10: the same match fails to compile on 3.9.0 and 3.3.8 with Not found: w.

//> using scala 3.10.0

import trailhead.*
import scala.jdk.CollectionConverters.*

def area(s: Shape): Double = s match
  case Shape.Circle(r)  => math.Pi * r * r
  case Shape.Rect(w, h) => w * h

@main def run(): Unit =
  val s = Surface.ROCK
  println((s, s.label, s.effort, s.ordinal, s.name))
  println(Surface.values.toList)
  println(Surface.valueOf("SNOW"))
  val label = s match
    case Surface.GRAVEL => "easy"
    case Surface.ROCK   => "careful"
    case Surface.SNOW   => "gear"
  println(label)
  val w = Waypoint("Pico", 2500)
  println(w)
  println(w.name + " " + w.elevationM)
  w match
    case Waypoint(n, e) if e > 2000 => println(s"$n is high")
    case Waypoint(n, _)             => println(s"$n is low")
  println(Waypoint.summit("Peak"))
  println(area(Shape.Circle(1.0)))
  println(area(Shape.Rect(2, 3)))
  val reg = Registry()
  reg.add(w)
  println(reg.size)       // no parentheses needed for a Java method
  println(reg.size())
  println(Registry.MAX)
  println(Registry.describe("-", "a", "b", "c"))
  println(Registry.describe("-"))
  val arr = Array("x", "y")
  println(Registry.describe("+", arr*))
  println(Registry.max(List[Integer](3, 9, 4).asJava))
  println(reg.find("Nope"))
  println(reg.note())
  println(Option(reg.note()).getOrElse("(none)"))
  try Waypoint("bad", -1) catch case e: IllegalArgumentException => println(e.getMessage)
(ROCK,rock,1.5,1,ROCK)
List(GRAVEL, ROCK, SNOW)
SNOW
careful
Waypoint[name=Pico, elevationM=2500]
Pico 2500
Pico is high
Waypoint[name=Peak, elevationM=3000]
3.141592653589793
6.0
1
1
5
a-b-c

x+y
2
null
null
(none)
negative elevation

Points to notice, in the order of the example:

  • Java static members are accessed through the class name (Registry.MAX, Surface.valueOf), because Scala has no static. The Java class is seen as a companion-like object; see Objects & Companions.

  • s.ordinal and s.name are the ordinary Enum methods. Java enums are not Scala 3 enum`s (see Enums), but a Java enum is matched with the same `case syntax.

  • The sealed interface Shape and its nested records are matched with constructor patterns. The compiler treats the Java sealed hierarchy as sealed, so exhaustivity is checked for it and for Java enums, exactly as for Scala types.

  • A Java varargs parameter (String... parts) accepts zero or more arguments, and a Scala Array or Seq is passed with the postfix splice * (arr*); see Methods & Parameters.

  • find and note returned null; see Nulls from Java.

The exhaustivity checks in the bullet above are real and behave as in Scala. Leaving a case out of a match on a Java enum or a Java sealed interface produces the usual warning:

//> using scala 3.10.0
import trailhead.*

def area(s: Shape): Double = s match
  case Shape.Circle(r) => math.Pi * r * r

def level(s: Surface): String = s match
  case Surface.GRAVEL => "easy"
  case Surface.ROCK   => "careful"
-- [E029] Pattern Match Exhaustivity Warning: M.scala:4:29
4 |def area(s: Shape): Double = s match
  |                             ^
  |                    match may not be exhaustive.
  |
  |                    It would fail on pattern case: _: trailhead.Shape.Rect
-- [E029] Pattern Match Exhaustivity Warning: M.scala:7:32
7 |def level(s: Surface): String = s match
  |                                ^
  |                                match may not be exhaustive.
  |
  |                                It would fail on pattern case: SNOW
2 warnings found

See Pattern Matching for how exhaustivity and sealed types work, and Records and Sealed Classes for the Java side.

Boxing and generics: Int versus Integer

Java generics work only over reference types, and Scala’s Int is not a java.lang.Integer. The compiler converts between them automatically for ordinary values (the val ix: Int = bx and val bx: Integer = 5 forms both compile), but it cannot do so where a Java type parameter carries a bound. Registry.max above is declared as <T extends Comparable<T>>, and Int does not implement Comparable[Int]. The first attempt fails:

println(Registry.max(List(3, 9, 4).asJava))
-- [E007] Type Mismatch Error: Main.scala:38:37
38 |  println(Registry.max(List(3, 9, 4).asJava))
   |                       ^^^^^^^^^^^^^^^^^^^^
   |         Found:    java.util.List[Int]
   |         Required: java.util.List[? <: T]
   |
   |         where:    T is a type variable with constraint <: Comparable[T]

The fix used in the example is to ask for a list of Integer explicitly, List[Integer](3, 9, 4).asJava, which printed 9. In Scala code that must call such APIs often, prefer java.lang.Integer and java.lang.Double at the border or use an Ordering-based Scala API instead, such as List.max.

A static field is assignable, but a compiler crash lurks

Java static fields are assignable from Scala. Plain assignment worked in every run:

//> using scala 3.10.0
import trailhead.*
@main def run(): Unit =
  Registry.created = Registry.created + 1
  println(Registry.created)
1

A compound assignment to the same field did not compile in this setup. With Registry.created += 1 (the Registry class loaded from the compiled javac output, as in all runs on this page) the compiler crashed on both 3.9.0 and 3.10.0, while 3.3.8 compiled and ran it:

Exception in thread "main" java.lang.AssertionError: an unexpected type representation reached the compiler backend while compiling M.scala: NoType.
	at dotty.tools.backend.jvm.BTypeLoader.bTypeFromType(BTypeLoader.scala:121)
	at dotty.tools.backend.jvm.BCodeSkelBuilder$PlainSkelBuilder.symInfoTK(BCodeSkelBuilder.scala:155)
	...

This crash was seen with a class loaded from a class file and was not tried against Java sources compiled by the build server. An instance field (p.visits += 1, shown in the next section) is fine, and so is the expanded form Registry.created = Registry.created + 1. Mutable Java statics are best avoided anyway.

Extending Java interfaces and classes

A Scala class can extend a Java class and mix in Java interfaces with with (or a comma list) in the usual way, and a Scala trait can extend a Java interface. Java default methods count as concrete members, abstract methods must be implemented, and super calls reach the Java implementations. Generic parameters, including wildcards such as List<? extends T>, are translated to Scala’s use-site-variance-free form; a Java Scorer<? super T> is seen as Scorer[? >: T].

// Scorer.java
package trailhead;

@FunctionalInterface
public interface Scorer<T> {
    int score(T item);
    default Scorer<T> twice() { return t -> 2 * score(t); }
    static <T> Scorer<T> constant(int n) { return t -> n; }
}

// Planner.java
package trailhead;

import java.util.List;
import java.util.function.Function;

public abstract class Planner {
    protected final String name;
    public int visits;
    protected Planner(String name) { this.name = name; }
    public abstract String plan(List<String> stops);
    public String banner() { return "Planner " + name; }
    public <T> int total(List<T> xs, Scorer<? super T> s) {
        int sum = 0;
        for (T x : xs) sum += s.score(x);
        return sum;
    }
    public String apply(Function<String, String> f, String s) { return f.apply(s); }
    public void run(Runnable r) { r.run(); }
}

// Gate.java
package trailhead;

public interface Gate {
    boolean open();
    default String status() { return open() ? "open" : "closed"; }
}
//> using scala 3.10.0

import trailhead.*
import java.util.List as JList
import scala.jdk.CollectionConverters.*

class RidgePlanner(n: String) extends Planner(n):
  override def plan(stops: JList[String]): String =
    s"$name: ${stops.asScala.mkString(" -> ")}"
  override def banner(): String = super.banner() + "!"

class Lock(var locked: Boolean) extends Gate:
  def open(): Boolean = !locked

@main def run(): Unit =
  val p = RidgePlanner("R")
  println(p.plan(List("a", "b").asJava))
  println(p.banner())
  p.visits += 1
  println(p.visits)
  // anonymous implementations
  val g = new Gate:
    def open() = true
  println(g.status())
  println(Lock(true).status())
R: a -> b
Planner R!
1
open
closed

Notes on the example:

  • Planner(n) is the superclass constructor call, and the protected final String name field is visible in the subclass.

  • The Java parameter List<String> appears as JList[String]. The import java.util.List as JList avoids a clash with scala.List; the rename syntax as is described in Packages, Imports & Top-Level Definitions.

  • A Java method with no parameters is overridden as def open(): Boolean, with the empty parameter list. See Auto-application of Java methods for what that implies at call sites.

  • An instance field such as visits is a plain Scala var: p.visits += 1 compiles and works.

Functional interfaces and SAM conversion

A Java interface with exactly one abstract method is a functional interface (Runnable, Comparator, java.util.function.*, and any interface such as Scorer). Scala converts a lambda to such an interface automatically when the expected type is known, which is SAM conversion (single abstract method). The same conversion applies to Scala traits with one abstract method. It makes Java APIs that take callbacks pleasant to use, and it is the reason a Scala function literal can be passed straight to Executor.submit, Collections.sort or a Java stream.

//> using scala 3.10.0

import trailhead.*
import java.util.List as JList
import scala.jdk.CollectionConverters.*

class RidgePlanner(n: String) extends Planner(n):
  override def plan(stops: JList[String]): String = n

@main def run(): Unit =
  val p = RidgePlanner("R")
  val byLen: Scorer[String] = s => s.length
  println(p.total(List("aa", "bbbb").asJava, byLen))
  println(byLen.twice().score("abc"))
  println(Scorer.constant[String](7).score("x"))
  println(p.apply(s => s.toUpperCase, "hi"))
  println(p.apply(_.reverse, "abc"))
  p.run(() => println("running"))
6
6
7
HI
cba
running

The expected type must be known for the conversion to happen. Where an overloaded Java generic method gives the compiler nothing to infer from, the lambda parameter has type Object and the compiler cannot see length on it, so give the type arguments explicitly:

//> using scala 3.10.0
import java.util.Comparator

@main def run(): Unit =
  val c = Comparator.comparing[String, Integer](_.length)
  println(c.compare("aa", "b"))
  val d: Comparator[String] = Comparator.comparingInt(_.length)
  println(d.compare("aa", "b"))
  val f = Comparator.comparing(_.length)
-- [E008] Not Found Error: V.scala:11:33
11 |  val f = Comparator.comparing(_.length)
   |                               ^^^^^^^^
   |                               value length is not a member of Object
1 error found

The first two forms compile: type arguments (comparing[String, Integer]) or an expected type (val d: Comparator[String]) are enough. Only the third, which has neither, fails; comparingInt is a convenient alternative because it avoids the Integer boxing. Scala’s own Ordering.by is simpler, and Comparator can be built from an Ordering when a Java API needs one (Ordering.by[String, Int](_.length) is itself a Comparator[String] because Ordering extends java.util.Comparator). For function values in general see Functions as Values.

Anonymous classes also work when a lambda is not possible, as new Gate: def open() = true in the previous section.

Java annotations

Java annotations are applied with the same @ syntax as Scala annotations, with the element values as arguments. The single element value can be given positionally, and other elements are passed by name. Array-valued elements are passed as Array(...), and nested annotations are passed as annotation expressions. Because the annotation here has RetentionPolicy.RUNTIME, the program can read it back with Java reflection:

// Tag.java
package trailhead;

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Tag {
    String value();
    int priority() default 0;
    String[] aliases() default {};
}
//> using scala 3.10.0
import trailhead.*

@Tag("trail")
class Annotated:
  @Tag(value = "m", priority = 2, aliases = Array("x", "y"))
  def m(): Unit = ()

@main def run(): Unit =
  val t = classOf[Annotated].getAnnotation(classOf[Tag])
  println((t.value, t.priority, t.aliases.toList))
  val tm = classOf[Annotated].getMethod("m").getAnnotation(classOf[Tag])
  println((tm.value, tm.priority, tm.aliases.toList))
(trail,0,List())
(m,2,List(x, y))

Defaults of the Java annotation (priority = 0, no aliases) are filled in on the Java side. The reverse is not true: annotations written in Scala (extending scala.annotation.StaticAnnotation) are not emitted as runtime annotations that Java reflection can read. For the Scala side of annotations and for the annotations that mean something to the JVM (@volatile, @transient, @native, @throws, @SerialVersionUID) see Annotations & Visibility. Java’s @FunctionalInterface on an interface needs no Scala counterpart because SAM conversion looks only at the shape of the interface.

Nulls from Java

Java has null and no type distinction for it, so any Java method with a reference result may return null. In plain Scala 3, null is a member of every reference type (except under explicit nulls, below), so nothing stops a Java null flowing through a String value and failing later with a NullPointerException. In the program above, reg.find("Nope") and reg.note() printed null. The idiomatic defence is to wrap the value as soon as it arrives: Option(x) yields None for null and Some(x) otherwise, as shown by Option(reg.note()).getOrElse("(none)"). After that, normal Scala handling applies; see Option & Null Safety and, for Kotlin’s approach to the same seam, Kotlin Null Safety. Java’s Optional is the other way of avoiding it, see Optional.

Without any option, the unguarded access fails at the point where the value is used:

//> using scala 3.10.0
import trailhead.*
@main def run(): Unit =
  val reg = Registry()
  val n: String = reg.note()
  println(n)
  println(n.length)
null
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "n" is null
	at N$package$.run(N.scala:7)
	at run.main(N.scala:3)

Explicit nulls

Scala 3 has an opt-in mode, -Yexplicit-nulls, that removes Null from the reference types, so String no longer contains null and a nullable value has the union type String | Null (see Union & Intersection Types). Java code cannot be annotated with these types, so the compiler reads Java signatures as flexible types: a Java String result is seen as String | Null when that is convenient and as String when that is. The mode is documented in the Scala 3 reference.

//> using scala 3.10.0
//> using option -Yexplicit-nulls
import trailhead.*

@main def run(): Unit =
  val reg = Registry()
  val w: Waypoint | Null = reg.find("none")
  println(w)
  val n: String | Null = reg.note()
  println(n.nn.length)
null
Exception in thread "main" java.lang.NullPointerException: tried to cast away nullability, but value is null
	at scala.runtime.Scala3RunTime$.nnFail(Scala3RunTime.scala:32)
	at N$package$.run(N.scala:10)
	at run.main(N.scala:5)

The extension .nn ("not null") asserts non-nullness and throws a descriptive NullPointerException (tried to cast away nullability) when it is wrong, which is much easier to trace than an NPE deep in Java. Explicit nulls does not make Java safe, though. Because Java types are flexible, assigning a Java result straight to a non-null type still compiles, and the value is only checked when it is used:

//> using scala 3.10.0
//> using option -Yexplicit-nulls
import trailhead.*

@main def run(): Unit =
  val reg = Registry()
  val n: String = reg.note()
  println(n.length)
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "n" is null
	at N$package$.run(N.scala:8)
	at run.main(N.scala:5)

So the discipline is the same with or without the flag: wrap Java results in Option(...) at the boundary. What explicit nulls does prevent is Scala code accidentally producing or storing null: in the same mode val k: Int = null is a compile error, and val s: String = null too (both print Found: Null and Required: ...). The flag begins with -Y, the prefix of advanced options, so check Compiler Options, Linting & Errors before turning it on in a large codebase. The import scala.language.unsafeNulls switches the check off in a scope.

Auto-application of Java methods

In Scala 3, a method defined with an empty parameter list, def open(): Boolean, must be called with parentheses, l.open(). The dropped "auto-application" of Scala 2 would have allowed l.open. Java methods are exempt, because Java has no way of saying whether a method is a property or an action, and so are Scala methods that override a Java method. The next program shows all three behaviours, and the error for a plain Scala method:

//> using scala 3.10.0
import trailhead.*
import java.util.List as JList

class Lock(var locked: Boolean) extends Gate:
  def open(): Boolean = !locked

class Alarm:
  def ring(): String = "ring"
  override def toString(): String = "alarm"

class Banner(n: String) extends Planner(n):
  def plan(stops: JList[String]): String = "p"
  override def banner(): String = "b"

@main def run(): Unit =
  val reg = Registry()
  println(reg.size)            // Java method: ok
  println(reg.toString.length) // Object method: ok
  val l = Lock(false)
  println(l.open)              // Scala override of Java method: ok
  println(Banner("x").banner)  // Scala override of Java method: ok
  val a = Alarm()
  println(a.toString)          // overrides Object.toString: ok
  println(a.ring)              // plain Scala method: error
-- [E100] Syntax Error: A.scala:25:12
25 |  println(a.ring)
   |          ^^^^^^
   |          method ring in class Alarm must be called with () argument
   |
   | longer explanation available when compiling with `-explain`
1 error found

With that last line removed, the program printed 0, 27, true, b and alarm. The exemption means Java-style calls such as list.size and obj.hashCode keep working. See the Scala 3 reference on auto-application and Methods & Parameters.

Checked exceptions and varargs

Scala has no checked exceptions. A Java method that throws IOException can be called without try/catch, and the exception simply propagates when it is thrown. This is why a Scala caller must read the Javadoc to know what can fail; see Exceptions, boundary & Early Exit and Either & Try for converting thrown exceptions into values.

// Reader.java
package trailhead;

import java.io.IOException;

public class Reader {
    public String read(String path) throws IOException {
        if (path.isEmpty()) throw new IOException("empty path");
        return "data:" + path;
    }
}
//> using scala 3.10.0
import trailhead.*
import scala.util.Try

@main def run(): Unit =
  val r = Reader()
  println(r.read("f"))                      // no try needed
  try r.read("") catch case e: java.io.IOException => println("caught " + e.getMessage)
  println(Try(r.read("")))                  // turn the exception into a value
  println(r.read(""))                       // not caught: propagates
data:f
caught empty path
Failure(java.io.IOException: empty path)
Exception in thread "main" java.io.IOException: empty path
	at trailhead.Reader.read(Reader.java:7)
	at T$package$.run(T.scala:10)
	at run.main(T.scala:5)

Java varargs (String...) take zero or more arguments, or a splice. Both Registry.describe("-", parts*) with a List[String] and parts.toArray* compiled and printed a-b in a separate run: Scala converts a Seq to an array for a Java varargs call.

Virtual threads and other new JDK APIs

Anything in the JDK is available the moment the JDK supports it, because Scala compiles against the JDK class path and needs no binding code. JDK 21 introduced virtual threads, and they work from Scala 3 with lambdas as Runnable and Callable. Note the type ascription that gives the lambdas a Callable type when they are placed in a List. Scala’s own Using.resource closes the executor, which implements AutoCloseable. See Virtual Threads for the Java side.

//> using scala 3.10.0
import java.util.concurrent.{Callable, Executors}
import scala.jdk.CollectionConverters.*
import scala.util.Using

@main def run(): Unit =
  val tasks: List[Callable[String]] = List("a", "b", "c").map(s => (() => s.toUpperCase): Callable[String])
  Using.resource(Executors.newVirtualThreadPerTaskExecutor()) { ex =>
    val results = ex.invokeAll(tasks.asJava).asScala.map(_.get()).toList
    println(results)
  }
  val t = Thread.ofVirtual().start(() => println("virtual: " + Thread.currentThread().isVirtual))
  t.join()
  val r: Runnable = () => println("runnable")
  r.run()
  println(String.format("%s has %d trails", "Pico", 3))
  println("%s has %d trails".format("Pico", 3))
List(A, B, C)
virtual: true
runnable
Pico has 3 trails
Pico has 3 trails

Scala’s own concurrency types (Future, see Futures & Promises) run on Java executors, so the two models mix, and for a Kotlin comparison see Kotlin Coroutines Basics.

Pitfalls

  • A java.util collection is never a Scala collection: convert with asScala / asJava and remember that the result is a live wrapper unless you finish with toList, toMap or another copy.

  • asJava on an immutable Scala collection gives a read-only Java view that throws UnsupportedOperationException on writes.

  • Int is not Comparable[Int] and List[Int] is not java.util.List[Integer]: bounded Java generics need Integer.

  • A lambda needs an expected type to be converted; an overloaded generic method may need explicit type arguments.

  • Java null is not tracked by the type, with or without -Yexplicit-nulls; use Option(...) at the boundary.

  • Mutable Java static fields should not be updated with a compound assignment; see the crash above.

  • Java checked exceptions are not checked in Scala.

  • Java methods with no parameters can be called without parentheses, but Scala methods declared with () cannot.

Scala 3.9 LTS versus 3.10

  • The converters CollectionConverters, OptionConverters and StreamConverters, SAM conversion, Java annotation syntax, enum and sealed matching, -Yexplicit-nulls and auto-application exemption for Java methods are the same on the 3.3 LTS, 3.9 LTS and 3.10. Only the 3.10.0 run was done here.

  • Matching a Java record with a constructor pattern (case Shape.Rect(w, h), case Waypoint(n, e)) is new in 3.10. On 3.9.0 and 3.3.8 the record has no extractor and the match fails to compile (Not found: w for the w * h of area).

  • The generic signatures that Scala 3.10 writes into class files are more precise; this matters when Java calls Scala (see Calling Scala from Java) and not when Scala calls Java.

  • The Registry.created += 1 compiler crash was reproduced on 3.9.0 and 3.10.0 and not on 3.3.8. It was not checked in other situations.

  • On 3.10.0 the class path of these runs contained both scala3-library_3-3.10.0.jar and scala-library-3.10.0.jar.

Where the books are outdated

  • Older books use scala.collection.JavaConversions, which converted implicitly. On 3.10.0 import scala.collection.JavaConversions.* fails with value JavaConversions is not a member of collection. scala.collection.JavaConverters still compiles with a deprecation warning; scala.jdk.CollectionConverters has the same asScala and asJava methods and is the replacement.

  • Scala 2 code calls any method with an empty parameter list without parentheses; Scala 3 allows that for Java methods only (see Auto-application of Java methods).

  • The Scala 3 Book’s section on Java interoperability covers collections and Optional; it does not cover Java records, sealed interfaces, explicit nulls or virtual threads, which this page does.