apply
Call Erlang and JavaScript runtime functions at runtime, by string path —
without writing an @external declaration plus a hand-rolled
*_ffi.erl / *_ffi.mjs entry for every function you need.
import apply
pub fn main() {
// Erlang target
let assert Ok(3) = apply.call("erlang:length", #([1, 2, 3]))
// JavaScript target
let assert Ok(9) = apply.call("Math.max", #(9, 2))
}
The path format depends on the target you build for:
| Erlang | JavaScript | |
|---|---|---|
| Path format | "module:function" (bare name defaults to erlang) | "object.property.path" (resolved on globalThis) |
| Example | "erlang:length" | "Math.max" |
Installation
gleam add apply
API at a glance
Pick the level of control you need.
| Function | Module | Purpose |
|---|---|---|
call(path, args) | apply | Quick call: dispatch by target, errors flattened to String. |
apply(path, args) | apply/erl, apply/js | Same, but keeps the target’s structured error type. |
try_catch(func:, args:) | apply/erl, apply/js | Execute a fetched function, capturing throws as structured errors. |
get_erlang_function(path, arity) | apply/erl | Fetch a reusable Function handle (validates module/atom/arity). |
get_function_from_global(path) | apply/js | Fetch a reusable Function (keeps the correct this). |
get_obj_from_global(path) / get(obj, attr) / has(obj, attr) | apply/js | Fetch / read / test JS values by dotted path. |
classify(value) | apply/erl, apply/js | Branch on the runtime type with precise types (ErlValue / JsValue). |
classify(value) | apply | Cross-target classification into a portable apply.Value. |
tuple_to_list(value) | apply/erl | Turn an Erlang tuple into List(ErlObj) for per-element dispatch. |
tuple_to_list(value) | apply/js | Turn a JS array (a Gleam tuple) into List(JsObj). |
tuple_to_values(value) | apply/erl, apply/js | Same, but classify each element → List(ErlValue) / List(JsValue). |
atom_to_string(a) / string_to_atom(s) | apply/erl | Round-trip Erlang atoms. |
platform() / is_tuple() / is_function() / tuple_size() | apply/boundary | Runtime checks. |
to_custom_type(value) / identify(value) / debug_string(value) | apply/boundary | Low-level casting / tagging / rendering. |
call vs target-specific apply vs try_catch
apply.callis the shortcut: it dispatches by target and flattens errors to a readableString.erl.apply/js.applyare the same, but keep the target’sErlError/JsError, so you can pattern-match the failure.get_*thentry_catchis the most explicit: fetch a function once, keep it around, and separate lookup errors from execution errors. Use it to reuse a handle or to inspect aFuncRunningError(error_type/error_reason/error_stackare structured, not a string).
All of call / apply / try_catch return a generic success value, so you
can use the result directly as the concrete type you expect. Only when a
function can return more than one type do you need classify.
Quick start
import apply
// Erlang
let assert Ok(3) = apply.call("erlang:length", #([1, 2, 3]))
let assert Ok("123") = apply.call("erlang:integer_to_binary", #(123))
// JavaScript
let assert Ok(9) = apply.call("Math.max", #(9, 2))
Caveats:
argsmust be a tuple; the arity is taken from its length.- The success value is untyped (
a) — nothing is checked at runtime. - On an unsupported target it returns
Error("unsupported runtime: ...").
Fetch, then execute
Use apply/erl / apply/js when you want reusable handles or structured
errors.
import apply/erl
// Fetch and call separately: `length` is a reusable handle.
let assert Ok(length) = erl.get_erlang_function("erlang:length", 1)
let assert Ok(v) = erl.try_catch(length, #([1, 2, 3]))
// Or in one step
let assert Ok(v2) = erl.apply("erlang:length", #([1, 2, 3]))
// Atoms are opaque values you can round-trip
let assert Ok(atom) = erl.string_to_atom("hello")
let assert Ok("hello") = erl.atom_to_string(atom)
import apply/js
let assert Ok(math) = js.get_obj_from_global("Math")
let assert Ok(pi) = js.get(math, "PI")
let assert Ok(max) = js.get_function_from_global("Math.max")
let assert Ok(9) = js.try_catch(max, #(9, 2))
get_function_from_global remembers the correct this, so method calls such as
"console.log" keep working.
Branching on the runtime type — classify
Because apply / try_catch return a generic a, the common case needs no
extra work:
let assert Ok(3) = erl.apply("erlang:length", #([1, 2, 3]))
When the function may return different runtime types, use classify. It
returns a variant that carries the concrete value, so you match on constructors
instead of strings.
Erlang — erl.classify
ErlValue variants: ErlBool, ErlInt, ErlFloat, ErlString,
ErlBitArray, ErlList(List(Dynamic)), ErlDict(Dict(Dynamic, Dynamic)),
ErlNil, ErlTuple(Dynamic), ErlFunction(Function), ErlAtom(Atom),
ErlLocal(ErlObj).
import apply/erl
let assert Ok(raw) = erl.apply("erlang:is_atom", #(1))
let assert erl.ErlBool(False) = erl.classify(raw)
JavaScript — js.classify
JsValue variants: JsBool, JsInt, JsFloat, JsString, JsBitArray,
JsList(List(Dynamic)), JsDict(Dict(Dynamic, Dynamic)), JsNil,
JsArray(Dynamic), JsFunction(Function), JsObject(JsObj).
import apply/js
let assert Ok(raw) = js.apply("Math.max", #(9, 2))
let assert js.JsInt(9) = js.classify(raw)
Erlang example — {X, Y} or false
Erlang functions often return a tuple on success and an atom (false) on miss.
tuple_to_values splits the tuple and classifies each element, so you can
match on the resulting List(ErlValue) directly:
import apply/erl
pub fn lookup(key: String, records: List(#(String, Int))) -> Result(Int, Nil) {
case erl.apply("lists:keyfind", #(key, 1, records)) {
Ok(raw) ->
case erl.tuple_to_values(raw) {
Ok([erl.ErlString(_), erl.ErlInt(v)]) -> Ok(v)
_ -> Error(Nil) // false: not found, or unexpected shape
}
Error(_) -> Error(Nil)
}
}
JavaScript example — JSON.parse
import apply/js
import gleam/int
import gleam/list
pub fn describe_json(text: String) -> String {
case js.apply("JSON.parse", #(text)) {
Ok(raw) ->
case js.classify(raw) {
js.JsInt(n) -> "int " <> int.to_string(n)
js.JsString(s) -> "string " <> s
js.JsArray(_) ->
case js.tuple_to_list(raw) {
Ok(items) -> "array of " <> int.to_string(list.length(items))
_ -> "array"
}
js.JsObject(_) -> "object"
_ -> "other"
}
Error(_) -> "invalid json"
}
}
Typing differences: on Erlang
5.0is a"float"; on JavaScript5.0is the number5and identifies as an"int". On Erlang any valid-UTF-8binaryis a"string", other binaries are"bit_array"; on JavaScriptstringandbit_arrayare distinct."atom"only occurs on Erlang.
Cross-target — apply.classify
For code that must run on both targets, apply.classify returns a portable
apply.Value (VBool, VInt, VFloat, VString, VBitArray,
VList(List(Dynamic)), VDict(Dict(Dynamic, Dynamic)), VNil,
VTuple(Dynamic), VFunction(Dynamic), VAtom(Dynamic), VLocal(Dynamic)).
Handle-like values are Dynamic because the two runtimes have different
concrete handle types.
import apply
import gleam/float
pub fn as_int(value: a) -> Int {
case apply.classify(value) {
apply.VInt(n) -> n
apply.VFloat(f) -> float.round(f)
_ -> 0
}
}
Decoding collections — to_custom_type and gleam/dynamic
VList / VDict / ErlList / JsDict elements are Dynamic, so they plug
into the standard gleam/dynamic/decode decoders:
import apply/erl
import gleam/dynamic/decode
import gleam/list
import gleam/result
let assert Ok(raw) = erl.apply("lists:seq", #(1, 3))
let assert erl.ErlList(items) = erl.classify(raw)
let assert Ok([1, 2, 3]) =
items
|> list.map(decode.run(_, decode.int))
|> result.all
boundary.to_custom_type(value) is an identity cast (no runtime work): it
lets you assert a concrete type when you already know it, e.g.
let #(_, found) = boundary.to_custom_type(tuple).
Error handling
Every error variant has a matching format_error, producing messages in a
shared path/arity + reason style.
| Scenario | Erlang | JavaScript |
|---|---|---|
| Malformed path | path "Math.max" is not in "module:function" form | resolved via Reflect, failure surfaces as property "..." does not exist on ... |
| Target missing | module atom "not_a_module" in path "not_a_module:foo" does not exist | property "notExist" does not exist on ... |
| Not exported at that arity | function "length/2" is not exported by module "erlang" | — (JS has no arity check) |
| Not callable | not a callable function: ... | path "Math.PI" is not a function |
| Args not a tuple | args must be a tuple, got ... | args must be a tuple, got ... |
| Threw while running | error: badarg (+ stack lines) | TypeError: Reflect.get called on non-object (+ stack lines) |
| Wrong runtime | needs "erlang" runtime, but running on "javascript" | needs "javascript" runtime, but running on "erlang" |
FuncRunningError keeps error_type / error_reason / error_stack
structured, so you can inspect it instead of parsing the rendered message.
Shared helpers — apply/boundary
import apply/boundary
boundary.platform() // OnErlang | OnJavascript
boundary.platform_name() // "erlang" | "javascript"
boundary.is_tuple(#(1, 2)) // True
boundary.is_function(fn() { 1 }) // True
boundary.tuple_size(#(1, 2)) // Ok(2)
boundary.identify(value) // #("int" | "string" | ... , value)
boundary.to_custom_type(raw) // cast to the type you expect
Development
gleam test # default target
gleam test --target erlang
gleam test --target javascript
gleam format --check src test
Tests enable themselves based on the runtime; an Erlang toolchain or Node.js
must be on PATH as appropriate.
Further documentation: https://hexdocs.pm/apply/.
License
Apache-2.0 — see LICENSE.