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 within your src/ or lib/ directories. 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 — the exact rule is spelled out below.
Constructing ADTs from Erlang
Returning an Int or a Result across the FFI is easy because the shapes are
obvious. Returning a custom ADT is where people get tripped up, because the
tag atom is mangled from the module and constructor names and the exact form
isn't guessable. This section gives the precise rules so you don't have to
reverse-engineer them.
The tag atom
A constructor Ctor defined in module Mod is represented by the atom
mod_Ctor:
- The module part is lowercased, and dots become underscores:
Shapes→shapes,Data.Shapes→data_shapes,Std.Actor→std_actor. - The constructor part keeps its original casing:
CirclestaysCircle,InvalidCoststaysInvalidCost.
So Data.Shapes.Circle becomes the atom 'data_shapes_Circle'. Because the
constructor case is preserved, the atom almost always needs quoting in Erlang
('data_shapes_Circle', not data_shapes_Circle).
The value shape
A constructor is a tuple: the tag atom first, then one element per constructor argument, positionally.
type Shape = # in module Data.Shapes
| Circle Float -> {'data_shapes_Circle', F}
| Rect Float Float -> {'data_shapes_Rect', W, H} % multiple args stay flat
| Origin -> {'data_shapes_Origin'} % NO args is still a 1-tuple!
The last case is the one that bites: a nullary constructor is a one-element
tuple {'mod_Origin'}, not a bare atom 'mod_Origin'. If you return the bare
atom, the pattern match on the Saga side silently fails to match — there's no
type error at the boundary, so this can be a frustrating debug. When in doubt,
wrap it.
Records follow the same rule — the tag is the record name, fields are positional in declaration order:
record Named { label: String } # in Data.Shapes
Named { label: "x" } -> {'data_shapes_Named', <<"x">>}
Payloads nest exactly as you'd expect. Wrapped (Named { label: "x" })
where both live in Data.Shapes:
{'data_shapes_Wrapped', {'data_shapes_Named', <<"x">>}}
Worked example: a NIF returning a typed error
Say you have a NIF (or plain Erlang bridge) that can fail, and you want the failure to arrive as a typed Saga ADT rather than a stringly-typed error. Define the ADT in Saga:
module Auth
pub type AuthError =
| InvalidCost Int
| Expired
| Malformed String
deriving (Show, Eq)
@external("erlang", "auth_nif", "check")
pub fun check : String -> Result Unit AuthErrorPlace the .erl bridge alongside your Saga sources (e.g. in lib/) so the
build picks it up. Because {ok, V} | {error, E} already maps onto Result,
the Erlang side just returns {ok, unit} on success or {error, <ADT>} on
failure, where <ADT> is the mangled tuple for the module Auth:
-module(auth_nif).
-export([check/1]).
check(Token) ->
case validate(Token) of
ok -> {ok, unit};
{bad_cost, N} -> {error, {'auth_InvalidCost', N}};
expired -> {error, {'auth_Expired'}}; % nullary: 1-tuple
{malformed, Msg} -> {error, {'auth_Malformed', Msg}} % Msg is a binary
end.
On the Saga side this is just a Result you pattern match on, with the error
arm giving you the full typed AuthError:
case check token {
Ok () -> grant ()
Err (InvalidCost n) -> $"cost {n} is not allowed"
Err Expired -> "token expired"
Err (Malformed msg) -> $"malformed: {msg}"
}Confirming the representation
If you're unsure what a constructor compiles to, the compiler can tell you. Write a throwaway function that returns the value and read the generated Core Erlang:
pub fun probe : Unit -> Shape
probe () = Originsaga build
grep -r Origin _build/dev/*.core # shows: {'data_shapes_Origin'}This round-trip takes ten seconds and removes all doubt about casing, prefixing, and tuple arity.
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.