Scala.js & Scala Native
|
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), Scala.js 1.22.0 and Scala Native 0.5.12. Every example was compiled and run through Scala CLI 1.17.1 on JDK 26, Node.js 24.21 and Apple clang 21 on macOS (aarch64), checked on 2026-10-15. The two exceptions, a browser page and an sbt build, are labelled "not run here" where they appear. Scala.js 1.22.0 and Scala Native 0.5.12 both publish compiler plugins for Scala 3.9.0 (LTS) and 3.10.0, so everything here also applies to the 3.9 LTS line; the version-specific points are in Scala 3.9 LTS versus 3.10.
The Scala compiler produces JVM bytecode by default, but the same source can also be compiled to JavaScript (Scala.js) or to a
native executable (Scala Native). Both are compiler plugins that stop the pipeline after the front end and emit their own
intermediate representation instead of .class files, followed by a whole-program linker.
The standard library is largely the same on every platform: collections, Option, Either, Future, string interpolation
and most of java.lang, java.util and java.time are re-implemented for Scala.js and Scala Native. What differs is what sits
around it: threads, files, sockets, reflection, the JVM’s number formatting and (on Scala.js) the whole runtime environment.
Platforms with Scala CLI
Scala CLI chooses the platform per run, either with a command-line flag or a directive. --js and --native (or the
directives //> using platform js and //> using platform native) pick the target; --js-version///> using jsVersion and
--native-version///> using nativeVersion pin the toolchain (the 3.10.0 distribution’s Scala CLI defaults to 1.22.0 and
0.5.12). The first run downloads the toolchain from Maven Central, as for any other dependency.
//> using scala 3.10.0
//> using platform js
//> using jsVersion 1.22.0
@main def hello(): Unit =
println("Hello from Scala.js")
println(s"1 + 1 = ${1 + 1}")
Hello from Scala.js
1 + 1 = 2
Source: Scala CLI: Scala.js
Scala CLI compiled the program to .sjsir, linked it and ran the resulting JavaScript on the node found on the PATH
(Node.js is not downloaded for you). One practical note from the run above: by default Scala CLI links with a native
executable of the Scala.js linker that it downloads from a GitHub release page rather than from Maven Central. If you want
the linker to come from Maven Central only (a locked-down CI machine, for instance), pass --js-cli-on-jvm, which runs the
same linker on the JVM; the examples on this page were run that way.
The same source with Scala Native, using //> using platform native (the program is identical, only the directive differs):
//> using scala 3.10.0
//> using platform native
@main def hellon(): Unit =
println("Hello from Scala Native")
Hello from Scala Native
Scala Native compiles the program to LLVM IR and calls clang to produce the executable, so a C toolchain must be installed
(Xcode command line tools on macOS, clang on Linux).
Source: Scala CLI: Scala.js and Scala CLI: Scala Native
Scala.js
Scala.js compiles Scala to JavaScript. Its linker removes everything the program cannot reach, and calling JavaScript from Scala and the other way round is type-checked. This page covers the pieces you meet first.
Link modes and module kinds
The Scala.js linker has two modes. Fast linking (fastLinkJS, the default of scala run) links quickly and keeps the output
readable; full linking (fullLinkJS, --js-mode release in Scala CLI) runs more optimisations and Google Closure to minify.
For the small program used in the WebAssembly section below, the two outputs measured about 195 KB (fast) and 38 KB (full).
-rw-r--r-- 195440 out-js (fast link)
-rw-r--r-- 38582 out-full (full link, Closure: 0 error(s), 0 warning(s))
The module kind says how the output is packaged: NoModule (a script, the default), CommonJSModule (require) or
ESModule (import/export). Scala CLI’s --js-module-kind and //> using jsModuleKind take none, commonjs or es.
A browser page that loads the script with <script type="module"> wants es; Node.js scripts are happy with the default.
JavaScript interoperability
The three basic tools are:
-
js.Dynamicandjs.Dynamic.literal: untyped access to any JavaScript object, like theDynamictrait on the JVM. -
Facade types: a Scala
traitorclassextendingjs.Object(or@js.nativefor existing JavaScript classes and globals) that describes the shape of a JavaScript API with static types. -
Exports:
@JSExportTopLevelmakes a Scala definition visible to JavaScript.
//> using scala 3.10.0
//> using platform js
//> using jsVersion 1.22.0
import scala.scalajs.js
import scala.scalajs.js.annotation.*
@js.native
@JSGlobal("Math")
object JsMath extends js.Object:
def hypot(a: Double, b: Double): Double = js.native
trait TrailJson extends js.Object:
val name: String
val km: Double
@JSExportTopLevel("trailLabel")
def trailLabel(t: TrailJson): String = s"${t.name} (${t.km} km)"
@main def interop(): Unit =
println(JsMath.hypot(3, 4))
val t = js.Dynamic.literal(name = "Pico", km = 12.5).asInstanceOf[TrailJson]
println(trailLabel(t))
println(js.JSON.stringify(t))
val xs = js.Array(1, 2, 3).map(_ * 2)
println(xs.mkString(","))
println(js.typeOf(t))
println(new js.Date(2026, 9, 12).getFullYear())
5
Pico (12.5 km)
{"name":"Pico","km":12.5}
2,4,6
object
2026
JsMath is a facade for the global Math object: @js.native means "the implementation exists in JavaScript", and every
member’s body is js.native. TrailJson is a non-native trait extending js.Object, so it is a JS type (a plain JavaScript
object at run time, with no Scala class behind it) and js.JSON.stringify prints just its fields. Calling a Scala method from
JavaScript needs @JSExportTopLevel; with ESModule the name becomes a named export of the module, with CommonJSModule a
property of exports and with NoModule a global.
A frequent mistake is calling JavaScript constructors as if they were Scala case classes. The compiler’s message is precise but
unexpected when you come from java.util.Date:
println(js.Date(2026, 9, 12).getFullYear())
-- Error: interop.scala:29:18
29 | println(js.Date(2026, 9, 12).getFullYear())
| ^^^^
| too many arguments for method apply in object Date: (): String
js.Date(...) without new is the JavaScript function call Date(), which returns a string with the current time. Write new
js.Date(2026, 9, 12) for the constructor, the way you would in JavaScript.
The DOM with scala-js-dom
Browser APIs (document, Element, MouseEvent, fetch, localStorage) are described by the scala-js-dom library,
a collection of facade types. In Scala CLI the :: after the group id makes the _sjs1_3 artifact for Scala.js; in sbt 1.x it is
%%%, while in sbt 2 the plain %% adds the platform suffix itself (Cross-platform projects).
//> using scala 3.10.0
//> using platform js
//> using jsVersion 1.22.0
//> using dep org.scala-js::scalajs-dom::2.8.1
import org.scalajs.dom
case class Trail(name: String, km: Double)
val trails = List(Trail("Pico de Aneto", 18.5), Trail("Monte Perdido", 14.0))
@main def page(): Unit =
val list = dom.document.createElement("ul")
trails.foreach: t =>
val li = dom.document.createElement("li")
li.textContent = s"${t.name}: ${t.km} km"
list.appendChild(li)
dom.document.body.appendChild(list)
val button = dom.document.createElement("button")
button.textContent = "Count"
var clicks = 0
button.addEventListener("click", (_: dom.MouseEvent) =>
clicks += 1
button.textContent = s"Clicked $clicks times")
dom.document.body.appendChild(button)
This program compiles and links with Scala.js 1.22.0 and scalajs-dom 2.8.1. It is not run here in a browser: a browser (or
jsdom, which Scala CLI’s --js-dom would install through npm) was not available. Run under plain Node.js, it fails at the
first DOM access, which also shows what the facade is: a typed view of a global that must exist at run time.
ReferenceError: document is not defined
at $c_Ldom$package$.page__V (main.js:1093:14)
...
Node.js v24.21.0
The Scala 3 style used above is idiomatic: the event handler is a Scala lambda converted to a JavaScript function by SAM
conversion, and var clicks is captured like in any Scala closure. Note that the main method is named page and not dom:
a top-level @main def dom() would clash with the dom import ("method dom must be called with () argument").
The WebAssembly backend
Since Scala.js 1.22.0 the WebAssembly backend is considered stable. It compiles the same .sjsir files to a .wasm module plus
a small JavaScript loader instead of to JavaScript. The documented requirements are an ECMAScript 2022+ host, an engine with
WebAssembly 3.0 support and the ESModule module kind; the documentation lists Node.js 25, Chrome 137, Firefox 134 and Safari 26
as supported engines. Two limitations are documented and worth knowing before you switch: @JSExport and @JSExportAll are
silently ignored (JavaScript code cannot call the exported members of Scala classes, though @JSExportTopLevel of functions is
unaffected), and only one output module is emitted, which rules out js.dynamicImport and top-level exports into several
modules.
In Scala CLI the switch is the directive //> using jsEmitWasm true. Forgetting the module kind gives a clear error:
[error] Wasm output requires ES modules. Pass --js-module-kind es (or add //> using jsModuleKind es).
//> using scala 3.10.0
//> using platform js
//> using jsVersion 1.22.0
//> using jsMode fastLinkJS
//> using jsEmitWasm true
//> using jsModuleKind es
@main def wasm(): Unit =
val xs = (1 to 5).map(i => i * i)
println(xs.sum)
55
Packaging the same program (scala --power package wasm.scala -o out-wasm) writes three files, showing the output really is
WebAssembly:
-rw-r--r-- 5147 out-wasm/__loader.js
-rw-r--r-- 1577 out-wasm/main.js
-rw-r--r-- 163439 out-wasm/main.wasm
This ran under Node.js 24.21, which is older than the engines listed in the documentation (Node.js 25); it worked for this
small program, but the documented minimum is the one to rely on. The documentation also notes that Wasm output is
significantly faster when time is spent in Scala code and may be slower when the program is dominated by JavaScript
interop, and that the code size is about twice that of the JavaScript back end in fullLink mode.
Source: Scala.js: WebAssembly backend
Semantics that differ from the JVM
Scala.js represents Scala’s numeric types with JavaScript numbers. Int, Short, Byte and Char have exact 32-bit (or
smaller) semantics, Long is emulated, but Float and Double are both JavaScript doubles, so some results differ. The
same source on the three platforms:
//> using scala 3.10.0
@main def semantics(): Unit =
val x: Any = 1.0
println(s"isInstanceOf[Int]: ${x.isInstanceOf[Int]}")
println(s"toString: ${1.0}")
println(s"class: ${x.getClass.getName}")
println(s"float: ${1.0f / 3}")
println(s"vm: ${System.getProperty("java.vm.name")}")
| Output line | JVM | Scala.js | Scala Native |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
On Scala.js a Double that happens to hold an integer value is an Int, Byte and Short for run-time type tests, which
is why isInstanceOf[Int] is true and getClass reports the smallest matching class. The Float is stored as a double and
printed as one. JavaScript is single-threaded, so there are no threads and blocking waits cannot make progress, and some run-time checks that
the JVM performs are relaxed. The Scala.js semantics page lists all the differences.
Source: Scala.js: Semantics of Scala.js
Scala Native
Scala Native compiles Scala to a native executable through LLVM IR and clang. It provides its own Scala 3 standard library
and a re-implementation of much of the JDK, a garbage collector in the runtime (default immix; commix, boehm and none are also
selectable), and a foreign function interface for calling C.
//> using scala 3.10.0
//> using platform native
//> using nativeVersion 0.5.12
import scala.scalanative.unsafe.*
@extern
object libc:
def strlen(s: CString): CSize = extern
def getpid(): CInt = extern
def abs(i: CInt): CInt = extern
@main def ffi(): Unit =
Zone:
val name = toCString("Pico de Aneto")
println(libc.strlen(name))
println(libc.abs(-42))
println(libc.getpid() > 0)
println(sizeof[Long])
[info] Linking native code (immix gc, none lto) (109 ms)
[info] Total (10330 ms)
13
42
true
8
@extern marks an object whose members are implemented in C and found by the linker; extern is the placeholder body.
CString is a pointer to a null-terminated byte array (Ptr[CChar]) and CSize the platform’s size_t. A Zone is a region
of manually managed memory freed when the block ends; toCString allocates inside the zone given by the context parameter, which
is why the call is inside Zone:. unsafe in the package name
is accurate: pointers are not checked, and a wrong signature in an @extern declaration crashes at run time.
Build modes and binary size
Scala CLI’s --native-mode (or //> using nativeMode) selects debug (the default, fast compile), release-fast,
release-size or release-full; --native-lto enables link-time optimisation and --native-gc selects the collector. The
size of the Hello World executable above, packaged with scala --power package:
| Mode | Size of the executable (arm64 macOS) |
|---|---|
|
2,034,264 bytes |
|
1,686,152 bytes |
|
1,345,464 bytes |
The executables depend only on the system library (otool -L lists libSystem.B.dylib), start immediately and need no JVM,
which is the reason to choose Scala Native for command-line tools. release-fast took 17 seconds to build against 9 for
debug, so use debug while developing.
Which Scala Toolkit modules work where
The Scala Toolkit bundles MUnit, OS-Lib, uPickle and sttp. The Maven
Central artifacts of Toolkit 0.9.2 show which platforms each module supports (a platform has an _sjs1_3 or _native0.5_3
artifact if it is supported):
| Module | JVM | Scala.js | Scala Native |
|---|---|---|---|
MUnit ( |
yes |
yes |
yes |
uPickle (JSON) |
yes |
yes |
yes |
sttp client 4 (HTTP) |
yes |
yes |
yes |
OS-Lib (files and processes) |
yes |
no |
yes |
//> using toolkit 0.9.2 resolves to toolkit_sjs1_3 or toolkit_native0.5_3 when you pass --js or --native, and the shared
JSON program below was run unchanged on all three platforms:
//> using scala 3.10.0
//> using toolkit 0.9.2
import upickle.default.*
case class Trail(name: String, km: Double) derives ReadWriter
@main def shared(): Unit =
val json = write(Trail("Pico", 12.5))
println(json)
println(read[Trail](json))
println(System.getProperty("java.vm.name"))
{"name":"Pico","km":12.5}
Trail(Pico,12.5)
Java HotSpot(TM) 64-Bit Server VM (scala run shared.scala)
Scala.js (scala run shared.scala --js)
Scala Native (scala run shared.scala --native)
Source: Scala Toolkit: Introduction
OS-Lib has no Scala.js artifact because there is no portable file system in a browser, and so using it with --js fails to
compile:
//> using scala 3.10.0
//> using toolkit 0.9.2
@main def osjs(): Unit =
println(os.pwd.last)
-- [E006] Not Found Error: osjs.scala:5:10
5 | println(os.pwd.last)
| ^^
| Not found: os
1 error found
Source: Scala Toolkit: Introduction
The same file with --native printed the directory name scala-js-native. For tests, //> using test.toolkit 0.9.2 also works
on all three platforms; the MUnit suite below passes with scala test, scala test --js and scala test --native:
//> using scala 3.10.0
//> using test.toolkit 0.9.2
class TrailTests extends munit.FunSuite:
test("naismith hours"):
val hours = 12.0 / 5 + 800.0 / 600
assertEqualsDouble(hours, 3.7333, 0.001)
TrailTests:
+ naismith hours 0.002s
The pattern works for any library: find the artifact with the platform suffix. A library written in pure Scala with no JVM-only dependencies can be published for all three platforms.
Cross-platform projects
In sbt 1.x, a cross project has one directory of shared sources and one per platform, each with its own dependencies. The
sbt-crossproject plugins provide crossProject (version 1.4.0 is published for sbt 1 and sbt 2, but sbt 2 has its own
replacement, shown after this build). This build was not run here (sbt was not used for this page); the plugin versions
were checked on Maven Central, and the same sources were run through Scala CLI above.
// project/plugins.sbt
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.22.0")
addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.12")
addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.4.0")
addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.4.0")
// build.sbt
ThisBuild / scalaVersion := "3.10.0"
lazy val trailhead = crossProject(JVMPlatform, JSPlatform, NativePlatform)
.crossType(CrossType.Pure)
.in(file("trailhead"))
.settings(
libraryDependencies += "com.lihaoyi" %%% "upickle" % "4.4.3"
)
In sbt 1.x, %%% appends the platform suffix (_sjs1_3, _native0.5_3) in addition to the Scala suffix; %% would always resolve the
JVM artifact. CrossType.Pure uses shared/ plus a platform directory only if needed (js/, jvm/, native/), while
CrossType.Full creates the three platform directories by default. The Scala 2 form of the same build is identical
apart from scalaVersion, and a Scala 2.13 library can be built for the same three platforms.
sbt 2 builds the same thing without the crossproject plugins. Its built-in projectMatrix takes one jvmPlatform,
jsPlatform or nativePlatform call per platform and generates one subproject per platform and Scala version, and %% is
platform-aware, so it resolves _sjs1_3 or _native0.5_3 in the Scala.js and Native subprojects and %%% is no longer
needed. The Scala.js and Scala Native plugins are still added (both are published for sbt 2). This build follows the sbt 2
documentation and was not run here either:
// project/plugins.sbt (sbt 2)
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.22.0")
addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.12")
// build.sbt (sbt 2)
scalaVersion := "3.10.0"
lazy val trailhead = (projectMatrix in file("trailhead"))
.settings(
libraryDependencies += "com.lihaoyi" %% "upickle" % "4.4.3"
)
.jvmPlatform(scalaVersions = Seq("3.10.0"))
.jsPlatform(scalaVersions = Seq("3.10.0"))
.nativePlatform(scalaVersions = Seq("3.10.0"))
The sbt 2 changes as a whole are on sbt & Build Tools.
Comparison with Java and Kotlin
| Aspect | Scala | Java and Kotlin |
|---|---|---|
JavaScript |
Scala.js: whole program compiled from Scala source; uses ordinary Scala libraries published for |
Java has no official JavaScript target (TeaVM and GWT are third party); Kotlin/JS is a Kotlin Multiplatform target |
WebAssembly |
Scala.js Wasm backend, stable in 1.22 (JS interop semantics unchanged) |
Kotlin/Wasm (Kotlin Multiplatform) |
Native executable |
Scala Native (LLVM, own runtime and GC) |
Java: GraalVM Native Image (ahead-of-time compile of a JVM application, see the Java build and tooling page); Kotlin/Native (LLVM) |
Shared code |
|
Kotlin Multiplatform with |
The practical difference is that Scala treats the platforms as compiler plugins on the same language, so there is no
expect/actual: platform-specific code lives in separate source directories (or behind a trait and a platform given). Kotlin’s
multiplatform model is a first-class language feature; Scala’s is a build convention.
Pitfalls
-
Using a JVM-only library: Scala.js and Scala Native need artifacts published for the platform. A missing one shows as
Not foundat compile time or as a resolution error (osabove). -
Blocking in Scala.js: there is one thread, so
Await.resulton aFuturethat depends on the same event loop cannot make progress. Use callbacks orFuturecombinators and let the event loop run. -
Using
@JSExportwith the Wasm backend: it is ignored without warning; use@JSExportTopLevelfor functions and test the exported API on the target backend. -
Running DOM code under Node.js:
ReferenceError: document is not defined. -
Linking errors for unimplemented JDK members: the linker reports the method that is missing from the Scala.js or Scala Native re-implementation of the JDK; avoid it or use a library that wraps it per platform.
-
Forgetting that
clangmust be installed for Scala Native, and that release modes are slower to build. -
Relying on JVM number formatting:
1.0.toStringprints1on Scala.js.
Scala 3.9 LTS versus 3.10
Scala.js 1.22.0 and Scala Native 0.5.12 publish plugins for both 3.9.0 and 3.10.0, so every example on this page works with
//> using scala 3.9.0 (the programs use only syntax available since 3.3 LTS). The Toolkit 0.9.2 artifacts for both platforms
were built against the Scala 3.3 library (scala3-library_sjs1_3 3.3.7, scala3lib_native0.5_3 3.3.7+0.5.10) and run on
later compilers.
Where the books are outdated
Books written before 2024 describe Scala.js 1.x with sbt-scalajs only, Scala Native 0.4 and its NativeConfig build settings, and
a JavaScript backend without WebAssembly. They also predate Scala CLI as the way to try a platform with one flag, the Wasm backend
(experimental until 1.22.0) and the Toolkit modules for Scala.js and Scala Native. Prefer the current official documentation
for the build setup.