Interop
Saga compiles to Core Erlang and runs on the BEAM, so it can call any Erlang
or Elixir library directly. This page covers the FFI: the @external
annotation, bridge files for type-mismatched conventions, and the limitations
on effectful callbacks. For declaring and installing dependencies (Hex, git,
path), see Ecosystem.
Calling Erlang Functions
The @external annotation declares a function whose implementation lives in an
Erlang module:
@external("erlang", "lists", "reverse")
pub fun reverse : List a -> List aThe three arguments are the target (always "erlang"), the Erlang module name,
and the Erlang function name. A type signature is required. The compiler trusts
it and emits a direct foreign call with no runtime validation.
When it works directly
If the Erlang function's argument and return types already match Saga's BEAM representations, no extra work is needed:
@external("erlang", "erlang", "length")
pub fun length : List a -> Int
@external("erlang", "maps", "put")
pub fun put : k -> v -> Dict k v -> Dict k v where {k: Eq}This works for functions that take and return plain values (integers, floats,
binaries, lists, maps), {ok, V} | {error, E} (matches Result), and
true | false (matches Bool).
Bridge files
When an Erlang function's return convention doesn't match Saga's type
representations, you write a bridge file: a .erl file that adapts between
conventions.
For example, Erlang returns Value | undefined for optional values, but Saga
represents Maybe as {just, V} | {nothing}. A bridge converts between these:
# Int.saga
@external("erlang", "my_int_bridge", "parse")
pub fun parse : String -> Maybe Int%% my_int_bridge.erl
-module(my_int_bridge).
-export([parse/1]).
parse(S) ->
case string:to_integer(S) of
{N, []} -> {just, N};
_ -> {nothing}
end.
Place .erl bridge files anywhere in your project root (excluding _build/
and tests/). They are compiled alongside the generated Core Erlang files
automatically.
The -module(name) in the .erl file must match the module string in
@external.
Type representations
Bridge functions must return values matching these BEAM representations:
| Saga Type | BEAM Representation | Example |
|---|---|---|
Int | Integer | 42 |
Float | Float | 1.5 |
String | Binary | <<"hello">> |
Bool | Atoms true/false | true |
Unit | Atom unit | unit |
List a | Erlang list | [1, 2, 3] |
(a, b) | Tuple | {1, <<"hi">>} |
Ok v | {ok, V} | {ok, <<"data">>} |
Err e | {error, E} | {error, <<"fail">>} |
Just v | {just, V} | {just, 42} |
Nothing | {nothing} | {nothing} |
Custom Foo x | {module_Foo, X} | {shapes_Circle, 5} |
Note that Err maps to the atom error (not err), and Unit is the atom
unit (not an empty tuple). Custom ADT constructors are prefixed with the
module name in lowercase.
Limitation: effectful callbacks
Pure Saga functions can be passed across the FFI boundary and called from
Erlang. But effectful functions (those with a needs clause) cannot. The
compiler rewrites effectful functions into CPS form with extra hidden
parameters, so an Erlang function that tries to call one will get an arity
mismatch.
If you need a "wrap a callback in setup/teardown" pattern (transactions, locks,
resource handles), expose separate acquire and release primitives from the
bridge and call the Saga callback from Saga code, where the effect machinery
is available. See the finally pattern in
Handler Patterns for how this works in practice.