apply

Package Version Hex Docs

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:

ErlangJavaScript
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.

FunctionModulePurpose
call(path, args)applyQuick call: dispatch by target, errors flattened to String.
apply(path, args)apply/erl, apply/jsSame, but keeps the target’s structured error type.
try_catch(func:, args:)apply/erl, apply/jsExecute a fetched function, capturing throws as structured errors.
get_erlang_function(path, arity)apply/erlFetch a reusable Function handle (validates module/atom/arity).
get_function_from_global(path)apply/jsFetch a reusable Function (keeps the correct this).
get_obj_from_global(path) / get(obj, attr) / has(obj, attr)apply/jsFetch / read / test JS values by dotted path.
classify(value)apply/erl, apply/jsBranch on the runtime type with precise types (ErlValue / JsValue).
classify(value)applyCross-target classification into a portable apply.Value.
tuple_to_list(value)apply/erl, apply/js, apply/boundarySplit a tuple (a JS array) into a list of opaque elements (ErlObj / JsObj / generic a).
tuple_to_values(value)apply/erl, apply/js, applySplit a tuple/array and classify each element → List(ErlValue) / List(JsValue) / List(apply.Value).
atom_to_string(a) / string_to_atom(s)apply/erlRound-trip Erlang atoms.
platform() / is_tuple() / is_function() / tuple_size() / tuple_to_list()apply/boundaryRuntime checks / tuple splitting.
to_custom_type(value) / identify(value) / debug_string(value)apply/boundaryLow-level casting / tagging / rendering.

call vs target-specific apply vs try_catch

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:

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.0 is a "float"; on JavaScript 5.0 is the number 5 and identifies as an "int". On Erlang any valid-UTF-8 binary is a "string", other binaries are "bit_array"; on JavaScript string and bit_array are 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
  }
}

apply.tuple_to_values is the cross-target counterpart of erl.tuple_to_values / js.tuple_to_values: it splits a tuple (a JS array on that target) and classifies each element into List(apply.Value).

case apply.tuple_to_values(raw) {
  Ok([apply.VAtom(_), apply.VInt(n)]) -> Ok(n)
  _ -> Error(Nil)
}

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.

ScenarioErlangJavaScript
Malformed pathpath "Math.max" is not in "module:function" formresolved via Reflect, failure surfaces as property "..." does not exist on ...
Target missingmodule atom "not_a_module" in path "not_a_module:foo" does not existproperty "notExist" does not exist on ...
Not exported at that arityfunction "length/2" is not exported by module "erlang"— (JS has no arity check)
Not callablenot a callable function: ...path "Math.PI" is not a function
Args not a tupleargs must be a tuple, got ...args must be a tuple, got ...
Threw while runningerror: badarg (+ stack lines)TypeError: Reflect.get called on non-object (+ stack lines)
Wrong runtimeneeds "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.tuple_to_list(#(1, 2))   // Ok([1, 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.

✨ Search Document