Calling Scala from Java

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 and javac 26 on JDK 26 (checked on 2026-10-15). Scala compiles to ordinary JVM class files, so a Java program can call Scala code with no bridge, but the class files show Scala’s own vocabulary: an object is a class with a MODULE$ field, a trait is an interface with default methods, a List is scala.collection.immutable.List, and a function is a scala.Function1. This page shows what Java source has to write for each construct, which annotations improve the Java view, and which Scala designs are awkward to use from Java. The opposite direction is covered in Using Java from Scala. Everything described applies on the 3.3 LTS and 3.9 LTS lines as well, except where Scala 3.9 LTS versus 3.10 says otherwise. Only 3.10.0 was run for the examples, with two explicit comparisons against 3.9.0.

The Java examples use the Scala types of the trailhead scenario, a toolkit that plans hikes. For the Java side of the language see Java Collections Framework, Optional and Lambdas and Method References.

Building a mixed project

A Java compiler needs the compiled Scala classes and the Scala runtime jars. With Scala CLI the three steps are: compile the Scala sources to a class directory, ask Scala CLI for the class path (which includes scala3-library_3 and, since the standard library is published as its own artifact, scala-library), then compile and run the Java sources against both. The commands below were used for every example on this page. The build server was switched off with --server=false, so the Scala and Java sources are compiled by separate commands instead of by one mixed build:

scala compile --server=false -d out .
CP="out:$(scala compile --server=false -p .)"
javac -cp "$CP" -d jout Main.java
java -cp "jout:$CP" Main

In a real project a build tool does this for you: with sbt, the compileOrder setting selects Mixed (the default), JavaThenScala or ScalaThenJava. A project in which Scala code refers to Java code and the Java code refers to Scala code at the same time needs a mixed build (Scala parses the Java sources, then javac compiles them against the Scala output). That single-command form through the Scala CLI build server was not run here. See sbt & Build Tools and Scala CLI.

The Java program needs these jars at run time. On 3.10.0 they are scala3-library_3-3.10.0.jar and scala-library-3.10.0.jar, and scala compile -p printed both, together with the class directory of the project.

The Scala library used in this page

All the examples call one small Scala library, written with the trailhead types. It is shown in excerpts below and it is one file, Lib.scala, in package trailhead:

//> using scala 3.10.0
package trailhead

import java.io.IOException
import scala.annotation.{targetName, varargs}

trait Described:
  def name: String
  def describe(): String = s"trail $name"

case class Trail(name: String, lengthKm: Double) extends Described

object Trail:
  def quick(name: String): Trail = Trail(name, 1.0)
  val Default: Trail = Trail("Default", 0.5)

object Planner:
  val version = "1.0"
  var visits = 0
  def total(trails: List[Trail]): Double = trails.map(_.lengthKm).sum
  def find(trails: Seq[Trail], name: String): Option[Trail] = trails.find(_.name == name)
  def byName(trails: List[Trail]): Map[String, Trail] = trails.map(t => t.name -> t).toMap
  @varargs def names(prefix: String, rest: String*): String = (prefix +: rest).mkString(",")
  def namesNoVarargs(prefix: String, rest: String*): String = (prefix +: rest).mkString(",")
  @throws[IOException]("when the file is missing")
  def load(path: String): String =
    if path.isEmpty then throw IOException("empty") else "data:" + path
  def loadUnchecked(path: String): String =
    if path.isEmpty then throw IOException("empty") else "data:" + path
  def withDefault(a: Int, b: Int = 10): Int = a + b
  @targetName("plusTrail")
  def +(a: Trail): Int = 1
  def map(f: Int => String, n: Int): String = f(n)
  def lazyValue(x: => Int): Int = x + 1
  def sorted[A](xs: List[A])(using ord: Ordering[A]): List[A] = xs.sorted
  def twoLists(a: Int)(b: Int): Int = a + b

enum Level:
  case Low, High
  case Custom(n: Int)

enum Color:
  case Red, Green

class Box[A](val value: A):
  def map[B](f: A => B): Box[B] = Box(f(value))
  def get: A = value

extension (s: String) def shout: String = s.toUpperCase + "!"

def helper(n: Int): Int = n * 2
val pi2 = 6.28

What the class files contain

The tool javap (shipped with the JDK) lists the members of a class file. It is the quickest way to see what Java can call, because the Java compiler reads exactly the same members. The listings below were produced for the library above.

A trait with only abstract members becomes an interface, and a trait with concrete members becomes an interface with default methods (this is how Scala 2.12 and later compile traits):

public interface trailhead.Described {
  public abstract java.lang.String name();
  public static java.lang.String describe$(trailhead.Described);
  public default java.lang.String describe();
}

A case class and its companion object give one class Trail, a second class Trail$ for the object, and static forwarders in Trail for the companion members:

public class trailhead.Trail implements trailhead.Described, scala.Product, java.io.Serializable {
  public trailhead.Trail(java.lang.String, double);
  public java.lang.String describe();
  ...
  public java.lang.String name();
  public double lengthKm();
  public trailhead.Trail copy(java.lang.String, double);
  public java.lang.String copy$default$1();
  public double copy$default$2();
  public java.lang.String _1();
  public double _2();
  public static trailhead.Trail Default();
  public static trailhead.Trail apply(java.lang.String, double);
  public static trailhead.Trail fromProduct(scala.Product);
  public static trailhead.Trail quick(java.lang.String);
  public static trailhead.Trail unapply(trailhead.Trail);
}

A standalone object with no companion class, like Planner, becomes a final class whose members are all static forwarders (the singleton itself is in Planner$):

public final class trailhead.Planner {
  public static scala.collection.immutable.Map<java.lang.String, trailhead.Trail> byName(scala.collection.immutable.List<trailhead.Trail>);
  public static scala.Option<trailhead.Trail> find(scala.collection.immutable.Seq<trailhead.Trail>, java.lang.String);
  public static int lazyValue(scala.Function0<java.lang.Integer>);
  public static java.lang.String load(java.lang.String) throws java.io.IOException;
  public static java.lang.String loadUnchecked(java.lang.String);
  public static java.lang.String map(scala.Function1<java.lang.Integer, java.lang.String>, int);
  public static java.lang.String names(java.lang.String, scala.collection.immutable.Seq<java.lang.String>);
  public static java.lang.String names(java.lang.String, java.lang.String...);
  public static java.lang.String namesNoVarargs(java.lang.String, scala.collection.immutable.Seq<java.lang.String>);
  public static int plusTrail(trailhead.Trail);
  public static <A> scala.collection.immutable.List<A> sorted(scala.collection.immutable.List<A>, scala.math.Ordering<A>);
  public static double total(scala.collection.immutable.List<trailhead.Trail>);
  public static int twoLists(int, int);
  public static java.lang.String version();
  public static int visits();
  public static void visits_$eq(int);
  public static int withDefault(int, int);
  public static int withDefault$default$2();
}

Top-level definitions (helper, pi2 and the extension method shout) are collected in a class named after the source file, Lib$package:

public final class trailhead.Lib$package {
  public static int helper(int);
  public static double pi2();
  public static java.lang.String shout(java.lang.String);
}

The mapping, in table form:

Scala construct What Java sees How to use it

class C(val x: Int)

class C with a constructor and accessor x()

new C(1).x()

var v in a class

accessors v() and v_$eq(int)

o.v_$eq(o.v() + 1)

trait T

interface T (default methods for concrete members)

implements T

object O

class O$ with a static MODULE$ field, plus the class O with static forwarders

O.method() or O$.MODULE$.method()

companion object members

static forwarders in the class C, unless the class has a member of the same name

C.apply(...)

case class

class with copy, equals, toString, productArity and _1, _2…​

t.copy(...), Trail.apply(...)

top-level def and val

static members of the class File$package

Lib$package.helper(1)

extension method

static method with the receiver as first parameter

Lib$package.shout("hi")

enum (Scala 3)

abstract class E extending scala.reflect.Enum, constants in the class E$

E$.Red, E.fromOrdinal(0)

def f(a: Int)(b: Int)

one method with all parameters flattened

f(1, 2)

using / implicit parameter

an ordinary last parameter

pass it explicitly

Classes, case classes and companions

Constructing and reading a Scala class needs no ceremony. A case class also generates copy, equals, hashCode and a readable toString. The companion object’s members have two entry points: the static forwarder in the class (Trail.apply) and the real method on the singleton (Trail$.MODULE$.apply). The forwarder is shorter and is the one to use.

import trailhead.*;

public class Main {
    public static void main(String[] args) throws Exception {
        Trail t = new Trail("Ridge", 8.5);
        System.out.println(t + " " + t.name() + " " + t.lengthKm());
        System.out.println(Trail.apply("Pico", 12.0));          // static forwarder
        System.out.println(Trail$.MODULE$.apply("Pico", 12.0)); // the module instance
        System.out.println(Trail.quick("S"));
        System.out.println(Trail.Default());
        System.out.println(t.copy("Copy", 1.0));
        System.out.println(Planner.version());
        Planner.visits_$eq(3);
        System.out.println(Planner.visits());
        System.out.println(Lib$package.shout("hi"));
        System.out.println(Lib$package.helper(4) + " " + Lib$package.pi2());
    }
}
Trail(Ridge,8.5) Ridge 8.5
Trail(Pico,12.0)
Trail(Pico,12.0)
Trail(S,1.0)
Trail(Default,0.5)
Trail(Copy,1.0)
1.0
3
HI!
8 6.28

A var is exposed as a getter visits() and a setter visits_$eq(int): the name is the Scala setter name visits_= with the symbol = encoded as $eq. Java identifiers may contain $, so this is valid Java, but it is not pleasant; where a Java caller is expected, offer a method such as def setVisits(n: Int) or use a Scala class with explicit getters. A val is only a getter. See Objects & Companions and Case Classes for the Scala side.

A static forwarder is created only when it cannot clash with an instance member. If the class and the companion define a member with the same name, the forwarder is skipped and Java must go through MODULE$:

class Foo:
  def hello(): Int = 1
  def only(): Int = 3

object Foo:
  def hello(): Int = 2
  def other(): Int = 4
public class trailhead2.Foo {
  public trailhead2.Foo();
  public int hello();
  public int only();
  public static int other();
}
public final class trailhead2.Foo$ {
  public static final trailhead2.Foo$ MODULE$;
  public static {};
  public int hello();
  public int other();
}

Foo.other() is a static call and Foo.hello() is an instance method, so the companion’s version needs Foo$.MODULE$.hello(). Writing Foo.hello() in Java gives a compile error:

E6.java:4: error: non-static method hello() cannot be referenced from a static context
        System.out.println(Foo.hello());
                              ^

A Java program that calls Foo$.MODULE$.hello(), new Foo().hello() and Foo.other() printed 2 1 4.

Traits

A trait with abstract methods is the easiest to use: Java implements the interface and inherits the default methods. This is also how a Scala library offers extension points to Java users.

import trailhead.*;

public class Main {
    static class JTrail implements Described {
        public String name() { return "J"; }
    }

    public static void main(String[] args) {
        System.out.println(new JTrail().describe());
    }
}
trail J

A trait with state is not like that. A Scala trait that has a val or var with an initial value also needs an initializer that Scala runs when a class mixes the trait in, and Java never runs it. Take this trait:

trait Limited:
  val limit: Int = 42
  def room: Int = limit - 1

class ScalaLimited extends Limited
public interface trailhead.Limited {
  public static void $init$(trailhead.Limited);
  public abstract int limit();
  public abstract void trailhead$Limited$_setter_$limit_$eq(int);
  public static int room$(trailhead.Limited);
  public default int room();
}

The interface has a synthetic abstract setter that a Java class must implement before it even compiles:

static class JLimited implements Limited {
    private int limit;
    public int limit() { return limit; }
}
E5.java:3: error: JLimited is not abstract and does not override abstract method trailhead$Limited$_setter_$limit_$eq(int) in Limited
    static class JLimited implements Limited {
           ^
1 error

Implementing it makes the class compile, but the value 42 is missing, because the initializer Limited.$init$ was never called. The call can be made by hand from a constructor, which restores the value:

public class E5b {
    static class JLimited implements Limited {
        private int limit;
        public int limit() { return limit; }
        public void trailhead$Limited$_setter_$limit_$eq(int n) { limit = n; }
    }
    public static void main(String[] a) {
        System.out.println(new ScalaLimited().room());
        System.out.println(new JLimited().room());
        JLimited j = new JLimited();
        Limited.$init$(j);
        System.out.println(j.room());
    }
}
41
-1
41

The lesson is to keep traits meant for Java implementors stateless: abstract and concrete def`s only, no `val or var with an initializer. A trait with a plain abstract var count: Int and a concrete method also works only if the Java class implements the accessors itself. See Traits & Mixins.

Collections

Scala collections are Scala classes, so the Java signature of Planner.total takes a scala.collection.immutable.List<Trail>. To build one from a Java collection use scala.jdk.javaapi.CollectionConverters, which is the Java-friendly twin of scala.jdk.CollectionConverters (the Scala one is made of extension methods, which Java cannot call). Its static methods are called asScala and asJava and are overloaded by argument type. The javaapi package has one converter object for each kind of conversion: CollectionConverters, OptionConverters, StreamConverters, DurationConverters, FutureConverters and FunctionConverters.

import trailhead.*;
import scala.Option;
import scala.Some;
import scala.collection.immutable.List;
import scala.jdk.javaapi.CollectionConverters;
import scala.math.Ordering;

public class E9 {
    public static void main(String[] args) {
        List<String> names = List.from(CollectionConverters.asScala(java.util.List.of("pico", "ridge", "lake")));
        System.out.println(names.size() + " " + names.head() + " " + names.apply(1) + " " + names.mkString(","));
        java.util.List<String> back = CollectionConverters.asJava(names);
        System.out.println(back);
        System.out.println(Planner.sorted(names, Ordering.String$.MODULE$));
        Option<String> a = Option.apply(null);
        Option<String> b = Some.apply("x");
        System.out.println(a + " " + b + " " + a.isEmpty() + " " + b.getOrElse(() -> "dflt") + " " + a.getOrElse(() -> "dflt"));
        System.out.println(b.map(s -> s + "!").get());
        Option<String> e = Option.empty();
        System.out.println(e);
        System.out.println(Planner.twoLists(1, 2));
        System.out.println(scala.jdk.javaapi.OptionConverters.toJava(b).get());
        System.out.println(scala.jdk.javaapi.OptionConverters.toScala(java.util.Optional.of(3)));
        scala.collection.immutable.Seq<Integer> sq = CollectionConverters.asScala(java.util.List.of(1, 2, 3)).toSeq();
        System.out.println(sq);
    }
}
3 pico ridge pico,ridge,lake
[pico, ridge, lake]
List(lake, pico, ridge)
None Some(x) true x dflt
x!
None
3
x
Some(3)
List(1, 2, 3)

Points to notice:

  • CollectionConverters.asScala(javaList) returns a mutable Buffer. List.from(...) copies it into an immutable scala.collection.immutable.List, which is what most Scala APIs take. asJava(names) wraps the Scala list as a read-only java.util.List without copying.

  • The instance methods of the Scala collections (size, head, apply, mkString) are callable from Java as usual. The higher-order ones (map, filter) take scala.Function1, which is a functional interface (see Functions).

  • scala.Option works with Option.apply(null) (gives None), Some.apply(x), Option.empty(), isEmpty, get and getOrElse. getOrElse takes a scala.Function0 for the default, so a Java lambda () -> "dflt" works. Go to java.util.Optional and back with scala.jdk.javaapi.OptionConverters.

  • The using Ordering[A] parameter of Planner.sorted became an ordinary second argument, and Java had to supply an Ordering object. Ordering.String$.MODULE$ is the string ordering. For Integer the same can be written as Ordering.comparatorToOrdering(java.util.Comparator.<Integer>naturalOrder()), and both forms compiled and ran.

  • Multiple parameter lists are flattened: Planner.twoLists(1, 2) for def twoLists(a: Int)(b: Int).

Passing Scala immutable collections to library APIs is cheap, but converting Java collections into them copies. For big data, design the Scala API to accept java.util.List or an Iterable and convert inside. A neater alternative is to offer a Java-friendly overload on the Scala side; see Collections Overview and Option & Null Safety. Kotlin faces the same split between kotlin.collections and java.util; see Kotlin Null Safety for that language’s treatment of the null boundary.

Functions

A Scala function type A => B is scala.Function1<A, B>, an interface with one abstract method (apply) and default methods (compose, andThen), so Java lambdas and method references convert to it directly. () => A is Function0 and a by-name parameter x: => Int is also a Function0 in Java. A Unit result is scala.runtime.BoxedUnit, because a generic type argument cannot be void.

import trailhead.*;
import trailhead2.*;

public class Main {
    public static void main(String[] args) {
        System.out.println(Planner.map(i -> "n=" + i, 4));
        System.out.println(Planner.lazyValue(() -> 41));
        System.out.println(Shapes.thunk().apply());
        Shapes.unitFun(i -> { System.out.println("got " + i); return scala.runtime.BoxedUnit.UNIT; });
        System.out.println(Shapes.intFun(i -> i * 10));
    }
}
n=4
42
5
got 1
20

The Scala definitions used above, besides Planner, are in Shapes:

object Shapes:
  def thunk: () => Int = () => 5
  def unitFun(f: Int => Unit): Unit = f(1)
  def intFun(f: Int => Int): Int = f(2)

The Java-friendly alternative is to accept a java.util.function.Function (or a Runnable, Supplier…​) on the Scala side and call it with apply. scala.jdk.javaapi.FunctionConverters converts between the two families. See Functions as Values and Lambdas and Method References.

Exceptions with @throws

Java checks exceptions at compile time and Scala does not. The Java compiler only knows an exception can be thrown if the class file says so, which is what @throws records. Compare the two methods of Planner: both throw IOException, but only load is annotated. The javap output above showed load(String) throws java.io.IOException and loadUnchecked(String) without a throws clause. A catch of the checked exception compiles for the first and is rejected for the second:

import trailhead.*;

public class E1 {
    public static void main(String[] a) {
        try {
            System.out.println(Planner.loadUnchecked(""));
        } catch (java.io.IOException e) {
            System.out.println("never");
        }
    }
}
E1.java:6: error: exception IOException is never thrown in body of corresponding try statement
        } catch (java.io.IOException e) {
          ^
1 error

With the annotated Planner.load("") the same shape of try compiled, and the exception was caught and printed caught empty. Add @throws to every Scala method that Java users are expected to handle. See Scala 3 Book: How to handle Scala methods that throw exceptions in Java code and Exceptions, boundary & Early Exit.

Varargs with @varargs

A Scala String* parameter is a Seq[String], so Java would have to build a Scala Seq to call such a method. The annotation @varargs makes the compiler generate a second, real Java varargs method. The javap listing above showed both names(String, Seq<String>) and names(String, String...) for the annotated names, and only the Seq form for namesNoVarargs. A call with several strings only works on the first:

import trailhead.*;

public class E3 {
    public static void main(String[] a) {
        System.out.println(Planner.namesNoVarargs("a", "b", "c"));
    }
}
E3.java:5: error: method namesNoVarargs in class Planner cannot be applied to given types;
        System.out.println(Planner.namesNoVarargs("a", "b", "c"));
                                  ^
  required: String,Seq<String>
  found:    String,String,String
  reason: actual and formal argument lists differ in length
1 error

With @varargs, Planner.names("a", "b", "c") printed a,b,c and Planner.names("a") printed a.

Symbolic names and @targetName

JVM method names cannot contain most symbols, so the compiler encodes them: + becomes $plus, :: becomes $colon$colon, and = in a setter becomes $eq. Java can call such a method because $ is legal in identifiers, but the result is unreadable. @targetName chooses the JVM name:

object Ops:
  def +(n: Int): Int = n + 1
  def ::(n: Int): Int = n
  @targetName("plusTwo")
  def ++(n: Int): Int = n + 2
public final class trailhead3.Ops {
  public static int $plus(int);
  public static int $colon$colon(int);
  public static int plusTwo(int);
}
System.out.println(Ops.$plus(1) + " " + Ops.plusTwo(1) + " " + Ops.$colon$colon(5));
2 3 5

The same annotation resolves clashes between overloads that have the same erased signature, which makes it useful beyond Java use. The Planner listing earlier showed @targetName("plusTrail") producing the Java method plusTrail. See Scala 3 Book: Create alternate names to use Scala methods in Java and the reference page for @targetName.

A related trap is a Scala name that is a Java keyword. short is a legal Scala method name, and Java cannot call it:

object Shapes:
  def short(n: Int): Int = n
System.out.println(Shapes.short(1));
E2.java:5: error: <identifier> expected
        System.out.println(Shapes.short(1));
                                  ^
E2.java:5: error: ';' expected
        System.out.println(Shapes.short(1));
                                       ^
2 errors

Use @targetName("shortOf") in that case, or choose another name. The same holds for int, class, new, default, switch and the other Java reserved words when they appear as Scala members.

Default arguments and @unroll

A method with default arguments is one method on the JVM, so Java must pass every argument. The compiler also generates accessor methods for the default values, named method$default$N, that Java can call:

System.out.println(Planner.withDefault(1, Planner.withDefault$default$2()));
11

A change that adds a defaulted parameter to a public method also changes its JVM signature, which breaks binary compatibility of existing callers, Java or Scala. The @unroll annotation of SIP-61 solves both problems: it generates one overload for each prefix of the optional parameters. In Scala 3.10 the feature is a preview feature (switched on with -preview; it was experimental in 3.9). The listing below was produced with //> using option -preview:

//> using scala 3.10.0
//> using option -preview
package trailhead2

import scala.annotation.unroll

object Defaults:
  def show(a: Int, b: Int = 10): Int = a + b
  def unrolled(a: Int, @unroll b: Int = 10, @unroll c: Int = 20): Int = a + b + c
public final class trailhead2.Defaults {
  public static int show(int, int);
  public static int unrolled(int);
  public static int unrolled(int, int);
  public static int unrolled(int, int, int);
  public static int show$default$2();
  public static int unrolled$default$2();
  public static int unrolled$default$3();
}
System.out.println(Defaults.unrolled(1) + " " + Defaults.unrolled(1, 2) + " " + Defaults.unrolled(1, 2, 3));
31 23 6

All three overloads are real methods, so Java callers get the ergonomics that Scala callers have. Preview features may change before they are final; see the SIP-61 page and Language Evolution: SIPs, Preview & Experimental.

Enums, opaque types and unions

A Scala 3 enum is a class that extends scala.reflect.Enum; it is not a java.lang.Enum, so it cannot be used in a Java switch over enum constants. The constants are static fields of the generated E$ class, and an enum with only simple cases (Color) also has static values(), valueOf and fromOrdinal on Color. An enum that has a parameterised case (Level.Custom(n)) has only fromOrdinal as a static method on the enum class, and the nested case class is Level.Custom:

System.out.println(Level$.High);                       // a parameterless case is a static field of Level$
System.out.println(Level.fromOrdinal(0));
System.out.println(Level.Custom.apply(5));
System.out.println(Color.values().length + " " + Color.valueOf("Green") + " " + Color$.Red);
High
Low
Custom(5)
2 Green Red

Types that exist only in the Scala compiler are erased in the signatures that Java sees. A union Int | String becomes Object, an opaque type becomes its underlying type, and a tuple is a scala.Tuple2<Integer, String>:

object Shapes:
  def either(x: Int | String): String = x.toString
  def pair(): (Int, String) = (1, "a")
  def firstLen(xs: List[String]): Option[Int] = xs.headOption.map(_.length)

opaque type Km = Double
object Km:
  def apply(d: Double): Km = d
def distance(k: Km): Km = k
public static java.lang.String either(java.lang.Object);
public static scala.Tuple2<java.lang.Integer, java.lang.String> pair();
public static scala.Option<java.lang.Integer> firstLen(scala.collection.immutable.List<java.lang.String>);
public static double distance(double);

A Java caller sees Object for the union and double for the opaque Km, so the type safety of opaque types exists only on the Scala side. See Enums, Opaque Types & Value Classes and Union & Intersection Types. A Java program calling these printed x3 for Shapes.either("x") + Shapes.either(3), 1 a for the pair and None for firstLen of an empty list.

More precise generic signatures in Scala 3.10

Scala 3.10 changed the generic signature stored in class files: primitive type arguments are now written as their boxed types. The JVM method descriptor (the erased signature) is the same, so Scala-to-Scala linking is not affected, but what javac reads from the generic signature changes. The same three-method object compiled with 3.9.0 and with 3.10.0:

package sig

object Api:
  def firstLen(xs: List[String]): Option[Int] = xs.headOption.map(_.length)
  def intFun(f: Int => Int): Int = f(2)
  def pair(): (Int, String) = (1, "a")
3.9.0
public final class sig.Api {
  public static scala.Option<java.lang.Object> firstLen(scala.collection.immutable.List<java.lang.String>);
  public static int intFun(scala.Function1<java.lang.Object, java.lang.Object>);
  public static scala.Tuple2<java.lang.Object, java.lang.String> pair();
}
3.10.0
public final class sig.Api {
  public static scala.Option<java.lang.Integer> firstLen(scala.collection.immutable.List<java.lang.String>);
  public static int intFun(scala.Function1<java.lang.Integer, java.lang.Integer>);
  public static scala.Tuple2<java.lang.Integer, java.lang.String> pair();
}

Java code written against the 3.9 signature, using Function1<Object, Object> and Option<Object>, compiles against 3.9.0 and fails against 3.10.0 with the following errors:

Function1<Object, Object> f = x -> (Integer) x + 1;
System.out.println(Api.intFun(f));
Option<Object> o = Api.firstLen(scala.collection.immutable.List$.MODULE$.empty());
E8.java:8: error: incompatible types: Function1<Object,Object> cannot be converted to Function1<Integer,Integer>
        System.out.println(Api.intFun(f));
                                      ^
E8.java:9: error: incompatible types: Option<Integer> cannot be converted to Option<Object>
        Option<Object> o = Api.firstLen(scala.collection.immutable.List$.MODULE$.empty());
                                       ^
2 errors

The new signatures are the more natural ones to program against: Function1<Integer, Integer> accepts i -> i * 10 directly, as in the earlier example. The release notes also say that a library republished with 3.10 can trigger MiMa IncompatibleSignatureProblem reports, and that MiMa 1.2.1 or later is needed to check Scala 3.9 and 3.10 artifacts. The 3.9 LTS remains the recommended line for libraries that need the longest support window, see the 3.10 release notes and Versions, Compatibility & JDKs.

Designing a Scala API that Java users enjoy

  • Use stateless traits (no initialised val or var) for extension points, or abstract classes with constructors.

  • Add @throws for exceptions callers should handle, @varargs for repeated parameters, and @targetName for symbolic operators and Java reserved words.

  • Prefer java.util.List, Optional, java.util.function.Function and Iterable in the public signature of an API meant for both languages; do the conversion inside.

  • Avoid using parameters, extension methods and opaque types in the Java-facing surface, because Java sees them as ordinary parameters, static methods and raw types.

  • Avoid a case class with a defaulted parameter in the public API of a library that Java calls; add an overload or use @unroll where the preview feature is acceptable.

  • Check the result with javap -cp classes your.Class: what is listed is exactly what Java can call.

Pitfalls

  • trait members with initialisers cannot be implemented from Java without calling $init$ by hand.

  • The member of a companion object has no static forwarder when the class has a member of the same name; use MODULE$.

  • scala.Option has no isPresent and its getOrElse and orElse take a Function0, not a value: use isDefined and getOrElse(() -> value), or convert to Optional.

  • Scala collections are immutable and have no add; CollectionConverters.asJava views are read-only for them.

  • Java sees Unit in a function type as BoxedUnit, and must return BoxedUnit.UNIT from such a lambda.

  • A method named after a Java keyword (short, int, new…​) cannot be called from Java.

  • The Scala runtime jars (scala-library and scala3-library_3) have to be on the Java class path at run time.

Scala 3.9 LTS versus 3.10

  • Traits as default-method interfaces, static forwarders, MODULE$, @throws, @varargs and @targetName are the same on 3.3 LTS, 3.9 LTS and 3.10.

  • Generic signatures use boxed type arguments from 3.10 on (Option<Integer>, not Option<Object>); the comparison above was run on 3.9.0 and 3.10.0.

  • @unroll is a preview feature on 3.10 (needs -preview); it was not run on 3.9.

  • The class path needs both scala3-library_3 and scala-library jars; on 3.10.0 both were version 3.10.0.

  • Not run here: the one-command mixed build through the Scala CLI build server and a Maven or sbt mixed project.

Where the books are outdated

  • Books and posts that predate Scala 2.12 describe traits as compiling to an interface plus an Impl class; since 2.12 and in Scala 3 a trait is one interface with default methods, as the listings above show.

  • The Scala 3 Book section on Scala collections in Java uses scala.jdk.javaapi.CollectionConverters; older Scala 2 code uses scala.collection.JavaConverters or JavaConversions, neither of which is the recommended path any more.

  • Scala 2 books do not mention @targetName, @unroll or the precise generic signatures of 3.10.

  • Scala 2 implicit parameters and Scala 3 using parameters look the same from Java: they are ordinary last arguments.