SagaSaga
Guide

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 a

The 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 TypeBEAM RepresentationExample
IntInteger42
FloatFloat1.5
StringBinary<<"hello">>
BoolAtoms true/falsetrue
UnitAtom unitunit
List aErlang 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: Shapesshapes, Data.Shapesdata_shapes, Std.Actorstd_actor.
  • The constructor part keeps its original casing: Circle stays Circle, InvalidCost stays InvalidCost.

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 AuthError

Place 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 () = Origin
saga 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.