Getting started¶
JErlang runs compiled Erlang on the JVM and ships its own compiler. The
workflow is the same either way: compile to a .beam, then load it. The
compile command needs no Erlang installation; beams produced by erlc still
load unchanged.
Build plugin (in progress)
A build plugin that runs .erl sources directly, without a separate
compilation step, is in progress. This page and the checked-in examples keep
the current explicit jerlang compile flow; the plugin will be documented
here when it is ready.
Two things to know before starting:
- Java 21 or newer is required, for building and for running.
- Erlang/OTP 29 is needed at run time if the code uses OTP's own modules
(
gen_server,supervisor,application,logger, …) or connects to an OTP node, and at compile time only if it includes OTP headers. A module that only useserlang,lists,mapsand the other built-in modules needs no Erlang installation at all.
Install¶
The Maven and Gradle artifact is the thin library jar: the runtime and its own
classes, with jzlib, jnr-posix, jline and commons-math3 declared as
normal dependencies. The runnable artifact is published under the all
classifier. It bundles those dependencies and has a Main-Class of
org.jerlang.erts.Emulator, so java -jar starts the Erlang shell with no
classpath setup. Use the all jar for a one-file launch and the thin artifact
as a dependency.
Check what you downloaded¶
Central serves a detached signature and checksums next to every artifact, and checking them against Central's own copies is what makes the check meaningful:
BASE=https://repo1.maven.org/maven2/io/github/slayyyyer/jerlang/3.0.0
curl -sO $BASE/jerlang-3.0.0-all.jar
curl -sO $BASE/jerlang-3.0.0-all.jar.asc
curl -s $BASE/jerlang-3.0.0-all.jar.sha256
gpg --verify jerlang-3.0.0-all.jar.asc jerlang-3.0.0-all.jar
# gpg: Good signature from "Justin Baker (jerlang release signing)"
# using RSA key 53566F66F97945DF654415F886F6991BB5D61373
Compile and run a module¶
java -jar jerlang-3.0.0-all.jar compile -o build hello.erl
java -jar jerlang-3.0.0-all.jar -pa build
compile takes one or more source files and creates the output directory if it
does not exist:
| Option | Meaning |
|---|---|
-o DIR |
Write .beam files to DIR (default .) |
-I DIR |
Add DIR to the -include search path |
-pa DIR |
Add DIR as a library root for -include_lib and parse-transform beams |
-Dname[=value] |
Define a macro; without a value it is true |
-W |
Lint and print warnings (-W0 disables, -Werror fails the build) |
+debug_info |
Accepted; line tables are already emitted, and transforms see the flag |
+warnings_as_errors |
Same as -Werror |
-- |
Treat the remaining arguments as source files |
-include("...") resolves next to the source and then under each -I
directory. -include_lib("app/include/file.hrl") resolves app under each
-pa root, using the app-version directory layout OTP uses. -Dname=value
scans the value as Erlang tokens, so -DLEVEL=2 defines an integer,
-DNAME=worker an atom, and -DFLAG the atom true, matching erlc.
-compile({parse_transform, Module}). and
-compile([{parse_transform, Module}]). run a transform after parsing and
before linting. The module must export parse_transform/2 and is loaded from a
-pa root, the working directory, or the ordinary code path. It runs in a
started JErlang VM and receives the forms plus OTP-shaped options:
debug_info, warnings_as_errors, {i, Dir} and {d, Name, Value}. Modules
beside the transform are loadable from -pa too, so a transform may call a
helper compiled into the same directory. The transform may itself have been
compiled by JErlang.
A transform that spawns an Erlang process has a 30 second default bound. The
Java API can change it with parseTransformTimeoutMillis; zero disables the
bound. A timeout is a compile error and does not write a beam. Parse transforms
are trusted compile-time code, not a sandbox.
The same compiler is available as a Java API when the command line is not the right surface:
Compiler.Options options = Compiler.Options.builder()
.outputDirectory(Paths.get("build"))
.includePath("include")
.define("DEBUG", null)
.warnings(true)
.build();
Compiler.Result result = Compiler.compileFile(Paths.get("hello.erl"), options);
if (!result.success()) {
for (Diagnostic diagnostic : result.diagnostics()) {
System.err.println(diagnostic.format());
}
}
Compiler.compile returns the beam bytes without writing a file.
At run time, -pa prepends a directory to the code path; -pz appends, and
ERL_LIBS works the way it does in OTP. The compile command's -pa is an
-include_lib library root, not a run-time code path. The module loader also looks in the working
directory, so a .beam next to where the JVM was started needs no flag.
The module search path is process-wide state. Node.start resets it and the
module registry, then adds the directories in NodeOptions; an embedding does
not call CodePaths.reset() itself. Close one node before starting the next.
Embed the runtime in Java¶
org.jerlang.api.Node owns the runtime: it starts the schedulers, applies the
options and, on close(), drains the Java object handle table. It implements
AutoCloseable, so the normal shape is try-with-resources.
import org.jerlang.api.Bridge;
import org.jerlang.api.Node;
import org.jerlang.type.Integer;
import org.jerlang.type.Term;
try (Node node = Node.start()) {
Term result = node.apply("hello", "greet", new org.jerlang.type.Str("java"));
Object greeting = Bridge.toJava(result); // "hello, java"
System.out.println(greeting);
// An Erlang fun is callable directly, too.
Term out = node.apply(anErlangFun, Integer.of(41));
}
Three things matter for a real application:
Nodeis the lifecycle. Creating it starts schedulers;close()stops them and releases handles. Do not create one per request.- Reflection is opt-in.
Node.start()registers nojavamodule, so Erlang code cannot reach arbitrary JVM classes. Ask for it explicitly:
With it, Erlang code can call Java directly:
Bind Java code into Erlang by name instead of by reflection with
registerFunction, which makes a lambda callable as a normal module
function:
- Object handles are bounded and explicit. A Java object that has no value
mapping comes back to Erlang as a handle;
java:release/1drops it andNode.close()drains the rest. The cap isNodeOptions.maxHandles, 100 000 by default, and exceeding it raisessystem_limit. There is no finalizer, so an unreleased handle keeps its Java object alive for the life of the node.
The full value mapping — which Erlang term becomes which Java type, and back —
is in Java interoperability. It is the compatibility
contract, and mvn verify fails on a change to it without a version bump.
For complete Maven, Gradle and Spring Boot quickstarts, including which paths need an Erlang installation, see Embedding in a JVM service. The packaging and lifecycle changes from 2.0 are in Migrating from 2.0 to 3.0.
Use OTP's own library¶
A gen_server is not reimplemented here. Compile the callback module, put
OTP's stdlib and kernel on the code path, and call it:
try (Node node = Node.start(NodeOptions.builder()
.otpRoot(Path.of(otpRoot)).build())) {
Term server = node.apply("counter", "start").toTuple().element(2);
node.apply("counter", "increment", server);
System.out.println(Bridge.toJava(node.apply("counter", "value", server)));
}
where otpRoot is code:root_dir() from the installation. otpRoot adds the
kernel and stdlib ebins for you, and the working directory remains the
default code path entry. The complete program is the
Java calls Erlang example.
You can also start from the other side and let an OTP node call this one; see
Distribution for the cookie, the node name and the
net_adm:ping/1 check.
Build from source¶
mvn test # the unit tests need no Erlang installation
mvn package
java -jar target/jerlang-<version>-all.jar
The build targets Java 21 with --release, so an accidental use of a newer
JDK's API is a compile error rather than a NoSuchMethodError at run time. CI
runs the suite on JDK 25.
Opt-in suites, all of which need a machine or a peer rather than the build:
mvn test -Dtest=OtpInteropTest -Djerlang.dist.otp=true # needs a live OTP 29 peer
mvn -Dtest=BenchTest -Djerlang.bench=true test # measures the machine
mvn -Dtest=DistributionBenchmarkTest -Djerlang.bench=true test # distribution codec
mvn jacoco:prepare-agent test jacoco:report
mvn pitest:mutationCoverage
Next¶
- Java calls Erlang and Erlang calls Java — the two directions, end to end.
- Distribution — clustering with OTP.
- Operating a node — metrics, logging and graceful shutdown.
- Benchmarks — what it costs against BEAM, measured on the same machine rather than asserted.