Skip to main content
Scala · lesson 11 of 11
Scala advanced Lesson 11 of 11

Calling Java from Scala, and Scala from Java

Convert java.util.Optional and java.util.List at the boundary, then read the javap output that shows why Java sees two .class files for one Scala object.

By SkillByExample · Updated

scala java-interop jvm javap scala-3

Scala and Java share one bytecode format, so nothing stops a Scala program from calling a java.util.List, or a Java program from calling a Scala object. What actually needs attention is the standard library: Java and Scala each have their own Optional/Option and collection types, and Scala’s object compiles to something Java code was never designed to call directly.

Java’s Optional and List, called from Scala

// Inventory.java
import java.util.Optional;
import java.util.List;
import java.util.ArrayList;

public class Inventory {
    public static Optional<String> findSku(String name) {
        if (name.equals("widget")) {
            return Optional.of("SKU-001");
        }
        return Optional.empty();
    }

    public static List<String> listNames() {
        List<String> names = new ArrayList<>();
        names.add("widget");
        names.add("gadget");
        return names;
    }
}
// CallJava.scala
//> using scala 3.6.2

import scala.jdk.OptionConverters.*
import scala.jdk.CollectionConverters.*

@main def callJavaRun(): Unit =
  val found: Option[String] = Inventory.findSku("widget").toScala
  val missing: Option[String] = Inventory.findSku("sprocket").toScala
  println(s"found: $found")
  println(s"missing: $missing")

  val javaNames = Inventory.listNames()
  val scalaNames: List[String] = javaNames.asScala.toList
  println(s"scalaNames: $scalaNames")
  println(s"scalaNames class: ${scalaNames.getClass}")
javac Inventory.java
scala run CallJava.scala --classpath . --main-class callJavaRun
found: Some(SKU-001)
missing: None
scalaNames: List(widget, gadget)
scalaNames class: class scala.collection.immutable.$colon$colon

Inventory.findSku(...) returns a java.util.Optional<String>, which Scala can call directly but cannot pattern-match like an Option. .toScala (from scala.jdk.OptionConverters) converts it. .asScala does the same job for the List — without it, javaNames would stay a java.util.ArrayList with none of Scala’s collection methods. The last line is the real surprise: printing the class of a converted Scala List shows scala.collection.immutable.$colon$colon, not List — List is a trait; :: (the cons cell, name-mangled to $colon$colon in bytecode) is the concrete class every non-empty List actually is.

A Scala object, compiled and inspected

// Pricing.scala
object Pricing:
  def discount(price: Double, pct: Int): Double =
    price * (1 - pct / 100.0)

  val defaultCurrency: String = "USD"
scalac Pricing.scala
ls *.class
Pricing$.class
Pricing.class

One object definition, two .class files. javap shows why:

javap -p Pricing.class
Compiled from "Pricing.scala"
public final class Pricing {
  public static java.lang.String defaultCurrency();
  public static double discount(double, int);
}
javap -p 'Pricing$.class'
Compiled from "Pricing.scala"
public final class Pricing$ implements java.io.Serializable {
  private static final java.lang.String defaultCurrency;
  public static final Pricing$ MODULE$;
  private Pricing$();
  public static {};
  private java.lang.Object writeReplace();
  public double discount(double, int);
  public java.lang.String defaultCurrency();
}

Pricing$.class is the real singleton: a private constructor, a MODULE$ static field holding the one instance, and the actual method bodies as instance methods on that instance. Pricing.class is a synthetic static forwarder — plain static methods that exist purely so Java code can write Pricing.discount(...) instead of the awkward Pricing$.MODULE$.discount(...). The Scala compiler generates the forwarder automatically; nothing in the source above asks for it.

Calling the object from Java

// CallScala.java
public class CallScala {
    public static void main(String[] args) {
        double price = Pricing.discount(100.0, 20);
        System.out.println("discounted: " + price);
        System.out.println("currency: " + Pricing.defaultCurrency());
    }
}

Put Pricing.scala and CallScala.java in the same directory and let scala run compile both languages together — it resolves the mixed sources on its own, no manual classpath needed:

scala run . --main-class CallScala
discounted: 80.0
currency: USD

Java calls Pricing.discount(...) through the static forwarder without ever referencing Pricing$ or MODULE$ — the two-file split is invisible from the call site. It only matters when something goes looking for it: a reflection-based framework, a serialization library, or a stack trace that names Pricing$ instead of Pricing will expose the mechanism this lesson just showed you directly.

Practice

1. Convert a Scala Option to a java.util.Optional and pass it to a Java method.
import scala.jdk.OptionConverters.*

val scalaOpt: Option[Int] = Some(42)
val javaOpt: java.util.Optional[Int] = scalaOpt.toJava
println(javaOpt.get())
42

.toJava is the mirror of .toScala — the conversion works in both directions from the same scala.jdk.OptionConverters import.

2. Run javap -p on a Scala case class and find the auto-generated methods a Java caller gets for free.
scalac Point.scala   # case class Point(x: Int, y: Int)
javap -p Point.class
public class Point implements scala.Product,java.io.Serializable {
  private final int x;
  private final int y;
  public static Point apply(int, int);
  public static Point fromProduct(scala.Product);
  public static Point unapply(Point);
  public Point(int, int);
  public scala.collection.Iterator productIterator();
  public scala.collection.Iterator productElementNames();
  public int hashCode();
  public boolean equals(java.lang.Object);
  public java.lang.String toString();
  public boolean canEqual(java.lang.Object);
  public int productArity();
  public java.lang.String productPrefix();
  public java.lang.Object productElement(int);
  public java.lang.String productElementName(int);
  public int x();
  public int y();
  public Point copy(int, int);
  public int copy$default$1();
  public int copy$default$2();
  public int _1();
  public int _2();
}

equals, hashCode, toString, and even tuple-style _1()/_2() accessors come from case class for free — a Java caller gets value equality and a constructor without writing a line of boilerplate, because the Scala compiler generated all of it at compile time, not through any interop layer.

3. Call a Scala method that takes a by-name parameter from Java, and see what actually gets passed.
// ByName.scala
object ByName:
  def logIfSlow(threshold: Int, msg: => String): Unit =
    if threshold > 10 then println(msg)
// CallByName.java — naive attempt
public class CallByName {
    public static void main(String[] args) {
        ByName.logIfSlow(20, "this is slow");
    }
}
CallByName.java:3: error: incompatible types: String cannot be converted to Function0<String>
        ByName.logIfSlow(20, "this is slow");
                             ^

The by-name parameter msg: => String compiles to scala.Function0<String> in bytecode — javap on ByName$.class shows public void logIfSlow(int, scala.Function0<java.lang.String>). Scala’s => String syntax hides this; Java code has to construct the thunk explicitly:

import scala.Function0;

public class CallByNameFixed {
    public static void main(String[] args) {
        ByName.logIfSlow(20, new Function0<String>() {
            public String apply() { return "this is slow"; }
        });
    }
}
this is slow
4. Make a Scala trait with a concrete method and implement it in a Java class.
// Greeter.scala
trait Greeter:
  def name: String
  def greet(): String = s"Hello, $name"
javap -p Greeter.class
public interface Greeter {
  public abstract java.lang.String name();
  public static java.lang.String greet$(Greeter);
  public default java.lang.String greet();
}
public class MyGreeter implements Greeter {
    public String name() { return "Java"; }

    public static void main(String[] args) {
        MyGreeter g = new MyGreeter();
        System.out.println(g.greet());
    }
}
Hello, Java

Scala 3 compiles a trait’s concrete method to a genuine Java default method — greet() needs no override. Add a val field to the trait instead of an abstract def and the Java implementation breaks: Java interfaces cannot hold instance state the way a Scala trait can.

Frequently Asked Questions

Do I need any special setup to call Java from Scala?
No. Scala compiles to the same JVM bytecode as Java, so any class on the classpath is directly usable — no wrapper, no annotation. The friction is in the standard library types: java.util.Optional and java.util.List are not scala.Option and scala.List, so you convert at the boundary with scala.jdk.OptionConverters and scala.jdk.CollectionConverters.
Why does compiling a Scala object produce two .class files?
A Scala `object` is a singleton, but the JVM has no native singleton-object concept — only classes. The compiler emits `Pricing$.class`, the real singleton holding a `MODULE$` static field and the actual method bodies, plus `Pricing.class`, a synthetic 'static forwarder' whose static methods just call through to `MODULE$`. Java code calls the forwarder and never has to know `MODULE$` exists.
Can a Java class extend a Scala trait?
Yes, if the trait has no fields and no non-abstract method bodies compiled before Scala 3's default-methods support — Scala 3 traits compile close enough to Java interfaces (with default methods) that this works for most traits. Traits with `val` fields or constructor logic do not translate cleanly and are best kept Scala-to-Scala.
What happens if I pass null from Java into a Scala function expecting a non-null type?
Nothing stops it at compile time. Scala's type system tracks null only if you enable `-Yexplicit-nulls`, which most projects do not. A Java caller passing null into a Scala method typed to take a plain String will compile fine and blow up with a NullPointerException the first time the Scala side dereferences it — the type system's guarantee is Scala-to-Scala only.