Skip to content

Latest commit

 

History

History
1327 lines (1010 loc) · 52.1 KB

File metadata and controls

1327 lines (1010 loc) · 52.1 KB

Fable.Beam Bindings Guide

How to write F# bindings for Erlang/OTP modules using Fable's BEAM backend.

Cross-Reference with Erlang Docs

Always verify your bindings against the official Erlang documentation at https://www.erlang.org/doc/readme.html. Each binding module should link to its corresponding Erlang doc page in a top-level /// comment (e.g., /// See https://www.erlang.org/doc/apps/erts/erlang).

When writing or reviewing a binding, check:

  • No bare obj — see "Core Rule: Avoid obj" below. Use generics, phantom types, DUs, records, or Dynamic instead.
  • Phantom type parameters on opaque handles (Pid<'Msg>, Ref<'Tag>, TableId<'K, 'R>)
  • Parameter types — match the Erlang typespec (e.g., if the doc says atom(), use Atom not obj)
  • Return types — match the Erlang typespec (e.g., {ok, pid()}Result<Pid<'Msg>, string>)
  • Arity — ensure all arities of a function are covered or the most common ones are bound
  • Charlist vs binary — the docs say string() for charlists; F# strings are binaries, so convert
  • Discrete atom sets — model as plain DUs with no fields (add [<CompiledName>] for snake_case atoms)
  • Callbacks — use plain F# function types ('T -> 'Acc -> 'Acc); they compile to an Erlang fun of the right arity everywhere, so System.Func<> is never required

Quick Reference

Pattern When to use Example
[<Emit>] BIFs, operators, inline Erlang code erlang:self(), $0 ! $1
[<Erase>] + [<ImportAll>] Binding an Erlang module with multiple functions timer, gen_server
[<Erase>] on DU Opaque Erlang types (compile-time safety, no runtime cost) Pid, Ref, TableId
[<Erase>] on generic DU Typed Erlang containers (maps, lists) BeamMap<'K,'V>, BeamList<'T>
Flattened default + *Raw variant Functions returning chardata/iodata or raw lists string:pad (→ string) + padRaw (→ BeamChardata) — see "Dual API"
[<Emit>] on abstract member Override ImportAll codegen for specific methods fable_utils:new_ref(...) wrapping
Plain F# function type Typed callbacks, in ImportAll interfaces and Emits alike fold, filter, foreach
U2<> / U3<> / erased union Parameters or returns that accept multiple types timeout: int or infinity
Regular DU (no fields) Discrete atom sets (table types, log levels) type EtsTableType = Set | Bag | ...
Dynamic + Decode combinators Genuinely unknown Erlang terms binaryToTerm, application:get_env

Core Rule: Avoid obj

obj is the maximum-entropy type — it admits any value, so using it in a binding signature defeats the purpose of typed bindings. When adding new bindings, treat obj as a last resort. Reach for the most specific type that fits, in this order:

  1. A concrete F# typeint, string, bool, Atom, unit
  2. A generic type parameter'Args, 'Msg, 'Key, 'Value when callers pick the type
  3. A phantom-typed opaque handlePid<'Msg>, Ref<'Tag>, TableId<'K, 'R>, ServerRef<'Call, 'Cast>
  4. A plain F# DU for discrete atom sets — compiles directly to atoms (see "DU cases compile to atoms")
  5. A record for structured Erlang maps — compiles to #{field => value} with atom keys
  6. An erased DU [<Erase>] type X = X of obj for opaque tagged wrappers (e.g., WsFrame, ServerName)
  7. U2<A, B> / U3<A, B, C> for parameters or returns that accept multiple known types
  8. Dynamic + Decode combinators for values whose shape is not known until runtime

Never write type X = obj — a bare alias silently admits anything. Use [<Erase>] type X = X of obj instead; the erased DU has zero runtime cost but prevents accidental mixing with other untyped terms.

The only narrow places obj is acceptable are listed in the "When obj is acceptable" section below.

Type Mappings: F# to Erlang

F# Erlang Notes
int, float integer(), float() Direct
string binary() <<"hello">>not charlists
BeamChardata unicode:chardata() binary/charlist/iolist; *Raw return, flatten with unicode:characters_to_binary — see "Dual API"
bool true | false Atoms
unit ok Atom — but Ok () in a Result is {ok, ok} (see bare-ok anti-pattern)
tuple tuple {A, B, C} — direct mapping
list<T> list() Both are linked lists
option<T> value or undefined Erased wrapper
Result<T,E> {ok, V} | {error, E} Matches Erlang idiom
record map #{field_name => Value}
DU (with fields) tagged tuple {tag, Field1, Field2}
DU (no fields) atom tag
bigint integer() Erlang has native arbitrary-precision ints

Using Specific Types Instead of obj

Erlang is dynamically typed, so it's tempting to use obj everywhere. But F# bindings should be as precise as possible — obj defeats the purpose of having typed bindings. Here's how to choose the right F# type for each Erlang value.

Primitives: always use the concrete type

These map directly and should never be obj:

// GOOD — concrete types
[<Emit("erlang:byte_size($0)")>]
let byteSize (bin: string) : int = nativeOnly

[<Emit("erlang:is_process_alive($0)")>]
let isProcessAlive (pid: Pid) : bool = nativeOnly

[<Emit("timer:sleep($0)")>]
let sleep (ms: int) : unit = nativeOnly
// BAD — obj hides the actual types
[<Emit("erlang:byte_size($0)")>]
let byteSize (bin: obj) : obj = nativeOnly

Tuples: use F# tuples

Erlang tuples map directly to F# tuples. Use them for fixed-size structured returns:

/// Returns the current date as {Year, Month, Day}.
[<Emit("erlang:date()")>]
let date () : int * int * int = nativeOnly

/// Returns the current local date and time as {{Year,Month,Day},{Hour,Minute,Second}}.
[<Emit("erlang:localtime()")>]
let localtime () : (int * int * int) * (int * int * int) = nativeOnly

/// Get the peer IP address and port.
[<Emit("cowboy_req:peer($0)")>]
let peer (req: Req) : obj * int = nativeOnly

Lists: use T list when the element type is known

When the Erlang function returns a homogeneous list with a known element type, use a typed list:

// GOOD — we know the element type
abstract supports: ``type``: Atom -> Atom list

// OK — element type is genuinely heterogeneous or unknown
abstract tab2list: table: TableId -> obj array

For [<ImportAll>] bindings that return Erlang lists, use obj array (since Fable treats raw Erlang lists as arrays in the interface). Use T list in [<Emit>] bindings where you control the return type:

/// Lists files in a directory. Converts charlist filenames to binaries.
[<Emit("...")>]
let listDir (path: string) : Result<string list, string> = nativeOnly

Option: use for "value or not found"

Map Erlang's various "not found" sentinels (undefined, false, eof, error) to option<T> by returning the atom undefined for None:

/// Reads a line from standard input. Returns None on EOF.
[<Emit("(fun() -> case io:get_line($0) of eof -> undefined; V__ -> erlang:list_to_binary(V__) end end)()")>]
let getLine (prompt: string) : string option = nativeOnly

/// Gets an environment variable. Returns None if not set.
[<Emit("(fun() -> case os:getenv(binary_to_list($0)) of false -> undefined; V__ -> ... end end)()")>]
let getenv (name: string) : string option = nativeOnly

Result: use for {ok, V} | {error, Reason}

This is Erlang's standard error convention. Map it directly to Result<'T, 'E>:

/// Reads a file. Returns Ok with contents or Error with reason.
[<Emit("(fun() -> case file:read_file(binary_to_list($0)) of ... end end)()")>]
let readFile (path: string) : Result<string, string> = nativeOnly

/// Writes data to a file.
[<Emit("(fun() -> case file:write_file(binary_to_list($0), $1) of ok -> {ok, ok}; ... end end)()")>]
let writeFile (path: string) (data: string) : Result<unit, string> = nativeOnly

Note: for functions that return bare ok (not {ok, Value}), wrap it as {ok, ok} in the Emit so it maps to Result<unit, string>. This wrapper is mandatory even for [<ImportAll>] interface members — a plain Result<unit, _> binding over a bare-ok function compiles but is silently wrong at runtime. See the "Result over a bare-ok function" anti-pattern below for why.

Records: use for structured Erlang maps

When an Erlang function returns a map with known fields, define an F# record:

type HttpResponse =
    { StatusCode: int
      Body: string }

// In Emit, construct the map with snake_case field names:
// #{status_code => StatusCode__0, body => erlang:list_to_binary(Body__0)}

F# records compile to Erlang maps with snake_case field names, so this roundtrips cleanly.

Opaque types: use [<Erase>] DUs with phantom type parameters

When Erlang has multiple kinds of opaque handles (pids, refs, table IDs, timer refs), wrap each in its own erased type so they can't be mixed up. Always add a phantom type parameter to capture additional information the F# type system can reason about:

/// Phantom 'Msg captures the message type this process accepts.
[<Erase>] type Pid<'Msg> = Pid of obj

/// Phantom 'Tag captures what the reference refers to (e.g., Ref<Pid<'Msg>> for a monitor).
[<Erase>] type Ref<'Tag> = Ref of obj

/// Phantom 'Msg captures the message delivered when the timer fires.
[<Erase>] type TimerRef<'Msg> = TimerRef of obj

/// Phantom 'Key and 'Row capture the ETS table's key and stored-row types.
[<Erase>] type TableId<'Key, 'Row> = TableId of obj

/// Phantom 'Call and 'Cast capture the gen_server's call/cast message types.
[<Erase>] type ServerRef<'Call, 'Cast> = ServerRef of obj

Then propagate the phantom through consuming functions:

// GOOD — phantom enforces that the sent message matches the process's mailbox type
[<Emit("$0 ! $1")>]
let send (pid: Pid<'Msg>) (msg: 'Msg) : unit = nativeOnly

// GOOD — monitor returns a Ref tagged with the monitored Pid
[<Emit("erlang:monitor(process, $0)")>]
let monitor (pid: Pid<'Msg>) : Ref<Pid<'Msg>> = nativeOnly

// BAD — no compile-time safety, any term can be sent
let send (pid: obj) (msg: obj) : unit = nativeOnly

For constructor functions that return a phantom-typed value, declare the generic explicitly (<'Msg>) so F# generalizes properly across bindings. Without it, F# value restriction may reject call sites:

// GOOD — explicit generic lets F# generalize across call sites
[<Emit("erlang:self()")>]
let self<'Msg> () : Pid<'Msg> = nativeOnly

// Subtly wrong — value restriction can make this non-generalizable
// let self () : Pid<'Msg> = nativeOnly

Key caveat: phantom typing guarantees local F# consistency, not cross-module correctness. A Pid<int> that was constructed for a process actually expecting strings is a lie the compiler cannot detect. Gate phantom construction through smart constructors where possible (spawn, whereis, makeRef<'Tag>).

DU cases compile to atoms (no [<StringEnum>] needed)

Plain F# discriminated union cases without fields compile directly to Erlang atoms on Fable BEAM. The default rule is: lowercase the first letter of the case name.

type RandAlg =
    | Exsss       // → atom exsss
    | Exro928ss   // → atom exro928ss
    | Exs1024s    // → atom exs1024s

Use this for discrete atom sets: table types, log levels, protocol atoms, option flags. Callers get compile-time checking that they're passing a valid atom — typos surface as F# errors instead of runtime function_clause.

For multi-word atom names where the default lowercased first letter isn't right, use [<CompiledName>]:

type EtsTableType =
    | Set                                             // → atom set
    | [<CompiledName("ordered_set")>] OrderedSet      // → atom ordered_set
    | Bag                                             // → atom bag
    | [<CompiledName("duplicate_bag")>] DuplicateBag  // → atom duplicate_bag

Verified: [<CompiledName>] on DU cases works on Fable BEAM (see TestEts.fs and TestRand.fs for round-trip confirmation via term_to_binary/binary_to_term).

Keep all cases field-less when the DU represents an atom set. DUs with fields compile to tagged tuples (Local of Atom{local, Atom}), which is useful for tagged unions but mixes two behaviours if the same type has both forms.

Dynamic + Decode combinators for unknown shapes

When an Erlang function returns something whose shape isn't known statically, return Dynamic instead of obj. Callers are forced through the Decode combinators to extract typed values — validation localised at the boundary, not sprinkled through the codebase.

// Binding
[<Emit("erlang:binary_to_term($0)")>]
let binaryToTerm (bin: string) : Dynamic = nativeOnly

// Caller narrows with decoders
let d = Erlang.binaryToTerm payload

match Decode.int d with
| Ok n -> printfn "got int %d" n
| Error msg -> eprintfn "decode failed: %s" msg

Available combinators (see src/otp/Dynamic.fs):

Combinator Signature
Decode.int / float / bool / atom / string Dynamic -> Result<T, string>
Decode.dynamic Dynamic -> Result<Dynamic, string> (identity)
Decode.field Atom -> (Dynamic -> Result<V, string>) -> Dynamic -> Result<V, string>
Decode.list (Dynamic -> Result<V, string>) -> Dynamic -> Result<V array, string>
Decode.optional (Dynamic -> Result<V, string>) -> Dynamic -> Result<V option, string>
Decode.tuple2 (Dynamic -> Result<A,_>) -> (Dynamic -> Result<B,_>) -> Dynamic -> Result<A * B, string>
Decode.succeed / map / andThen Result combinators

Composing a record decoder:

let userDecoder (d: Dynamic) : Result<User, string> =
    Decode.field (Atom.ofString "name") Decode.string d
    |> Result.bind (fun name ->
        Decode.field (Atom.ofString "age") Decode.int d
        |> Result.map (fun age -> { Name = name; Age = age }))

Note Atom.ofString, not Atom "name" — see "Erased constructors do not convert". Decoder callbacks are plain F# functions; no System.Func wrapper is needed at any arity — see "Callbacks: plain F# function types work at any arity" below.

Functions: use F# function types for callbacks

When an Erlang function takes a fun/callback, use the appropriate F# function type.

For [<Emit>] bindings, use curried F# function types:

/// Spawn a new process that executes the given function.
[<Emit("erlang:spawn(fun() -> $0(ok) end)")>]
let spawn (f: unit -> unit) : Pid = nativeOnly

Note: unit compiles to the atom ok, so $0(ok) calls the F# function with unit.

[<ImportAll>] interfaces use plain F# function types for callback parameters too. Fable compiles them to Erlang funs of the matching arity:

[<Erase>]
type IExports =
    /// Filters elements by a predicate.
    abstract filter: pred: ('T -> bool) * list: BeamList<'T> -> BeamList<'T>
    /// Left fold over a list.
    abstract foldl: f: ('T -> 'Acc -> 'Acc) * acc: 'Acc * list: BeamList<'T> -> 'Acc
    /// Applies a function to each element for side effects.
    abstract foreach: f: ('T -> unit) * list: BeamList<'T> -> unit
    /// Applies a function to each key-value pair in a map.
    abstract fold: f: ('K -> 'V -> 'Acc -> 'Acc) * init: 'Acc * map: BeamMap<'K, 'V> -> 'Acc

Usage — pass the lambda directly. Note the extra parentheses: the callback is one element of a tupled member's argument list, so an unparenthesised fun would swallow the rest.

lists.filter ((fun x -> x > 3), xs)
lists.foldl ((fun x acc -> acc + x), 0, xs)

System.Func<> / System.Action<> also work and generate identical Erlang — see "Callbacks: plain F# function types work at any arity" for the evidence. Prefer the plain form in new bindings; it costs the call site nothing.

Erased unions: use for parameters or results with multiple types

Erlang APIs sometimes accept or return values of different types. For example, a timeout might be an integer or the atom infinity, or a server ref might be a Pid, an Atom, or a {global, Name} tuple.

Use Fable.Core's erased union types (U2, U3, etc.) to express this without losing type safety:

open Fable.Core

/// A gen_server timeout: either milliseconds or the atom 'infinity'.
[<Erase>]
type Timeout =
    | Milliseconds of int
    | Infinity of Atom

    static member op_ErasedCast(x: int) = Milliseconds x
    static member op_ErasedCast(x: Atom) = Infinity x

Or use the built-in U2<'A, 'B> / U3<'A, 'B, 'C> types directly:

[<Erase>]
type IExports =
    /// Call with a timeout that is either an int or the atom 'infinity'.
    abstract call: serverRef: obj * request: obj * timeout: U2<int, Atom> -> obj

The op_ErasedCast static members enable implicit conversion, so callers can pass either type directly:

// Both work — the erased union accepts either type
gen_server.call (ref, msg, !^ 5000)
gen_server.call (ref, msg, !^ (Erlang.binaryToAtom "infinity"))

The !^ operator triggers the erased cast. At the Erlang level, the value is passed through unchanged — no wrapping or tagging.

When obj is acceptable

Very narrow use cases only. The default for anything "polymorphic" should be a generic parameter or Dynamic, not obj. Acceptable uses:

  • Genuinely type-irrelevant built-insphash2(Term, Range) -> int, exactEquals already fall back to obj or a free generic because any term is valid; wrapping every call site in Dynamic would yield zero benefit.
  • Backing field of an erased DU[<Erase>] type Pid<'Msg> = Pid of obj. The user never sees this; the phantom is what matters.
  • Raw IExports escape hatches — when the main API is a typed helper and the raw IExports is reserved as a fallback (e.g., File.fs).

For everything else that feels "polymorphic":

  • Caller decides the type? → generic parameter ('T, 'Args, 'Msg).
  • Process dictionary / message passing? → generic (get<'Key, 'Value>, send (pid: Pid<'Msg>) (msg: 'Msg)).
  • Unknown Erlang term?Dynamic and let the caller decode.
  • Multiple known types?U2<A, B> or a custom erased union.
  • Opaque reference?[<Erase>] type X<'phantom> = X of obj.

Never write type X = obj (a bare alias). Use [<Erase>] type X = X of obj instead.

Type choice decision tree

Is the Erlang type...
├── a number?                   → int, float, int64
├── a binary/string?            → string
├── chardata (iolist/charlist)? → string (default, flatten) + BeamChardata for a *Raw variant
├── a boolean atom?             → bool
├── the atom 'ok'?              → unit
├── a fixed-size tuple?         → T1 * T2 * ... (or Decode.tuple2/3 from Dynamic)
├── a known atom set?           → plain DU (no fields), [<CompiledName>] for snake_case
├── an open atom?               → Atom
├── {ok, V} | {error, R}?       → Result<T, string> (Emit wraps the tuple shape)
├── value | undefined?          → T option (IIFE converts undefined → None)
├── a map with known keys?      → F# record (compiles to #{field => value})
├── a map with arbitrary keys?  → BeamMap<K, V>
├── a list of known type?       → BeamList<T>  (or T array when you need F# array ops)
├── a list of tuples?           → BeamList<A * B>
├── an opaque handle?           → [<Erase>] type Foo<'phantom> = Foo of obj
├── one of N known types?       → U2<A,B> / U3<A,B,C> or custom erased union + op_ErasedCast
├── a callback fun?             → plain F# function type ('T -> 'U); any arity
├── a heterogeneous tagged tuple? → [<Erase>] DU + [<Emit>] constructors (see WsFrame, ServerName)
├── a discrete atom enumeration? → plain DU (no fields); see RandAlg, EtsTableType
├── genuinely unknown at runtime? → Dynamic + Decode combinators (never bare obj)
└── truly caller-polymorphic?   → 'T (never bare obj)

Pattern 1: [<Emit>] — Inline Erlang Code

Use [<Emit>] for Erlang BIFs, operators, and any expression that should generate inline Erlang code. Parameters are referenced with $0, $1, etc.

Simple BIF binding

open Fable.Core

/// Get the current process's pid.
[<Emit("erlang:self()")>]
let self () : Pid = nativeOnly

/// Create a unique reference.
[<Emit("erlang:make_ref()")>]
let makeRef () : Ref = nativeOnly

BIF with parameters

/// Send a message to a process (Pid ! Msg).
[<Emit("$0 ! $1")>]
let send (pid: Pid) (msg: obj) : unit = nativeOnly

/// Monitor a process. Returns a monitor reference.
[<Emit("erlang:monitor(process, $0)")>]
let monitor (pid: Pid) : Ref = nativeOnly

Returning tuples

/// Returns the current date as {Year, Month, Day}.
[<Emit("erlang:date()")>]
let date () : int * int * int = nativeOnly

/// Returns element at position N (1-based) in a tuple.
[<Emit("erlang:element($0, $1)")>]
let element (n: int) (tuple: obj) : obj = nativeOnly

Returning Result from Erlang {ok, V} | {error, Reason}

Erlang functions commonly return {ok, Value} or {error, Reason}. Map these to F#'s Result<'T, string> by pattern matching in the Emit expression:

[<Emit("""
(fun() ->
    case file:read_file(binary_to_list($0)) of
        {ok, FileReadData__} ->
            {ok, FileReadData__};
        {error, FileReadReason__} ->
            {error, erlang:atom_to_binary(FileReadReason__)}
    end
end)()
""")>]
let readFile (path: string) : Result<string, string> = nativeOnly

Returning Option from Erlang

When Erlang returns a sentinel value for "not found", convert it to option<T> by returning undefined for None:

[<Emit("""
(fun() ->
    case os:getenv(binary_to_list($0)) of
        false -> undefined;
        OsGetEnv__ -> erlang:list_to_binary(OsGetEnv__)
    end
end)()
""")>]
let getenv (name: string) : string option = nativeOnly

Emit values (not functions)

[<Emit>] also works for constant values:

/// Disable certificate verification (for development only).
[<Emit("[{ssl, [{verify, verify_none}]}]")>]
let verifyNone: SslOptions = nativeOnly

Pattern 2: [<Erase>] + [<ImportAll>] — Module Bindings

Use this pattern to bind an entire Erlang module. Define an interface with [<Erase>] describing the module's functions, then bind it with [<ImportAll("module_name")>].

Basic module binding

open Fable.Core

// fsharplint:disable MemberNames

[<Erase>]
type IExports =
    /// Suspends the process for Time milliseconds.
    abstract sleep: time: int -> unit
    /// Converts hours to milliseconds.
    abstract hours: hours: int -> int
    /// Converts minutes to milliseconds.
    abstract minutes: minutes: int -> int
    /// Converts seconds to milliseconds.
    abstract seconds: seconds: int -> int

/// timer module
[<ImportAll("timer")>]
let timer: IExports = nativeOnly

Usage:

timer.sleep 1000
let ms = timer.seconds 30  // 30000

Module with overloaded functions

Erlang functions with different arities map to overloaded abstract members:

[<Erase>]
type IExports =
    /// Makes a synchronous call to a gen_server.
    abstract call: serverRef: obj * request: obj -> obj
    /// Makes a synchronous call with timeout.
    abstract call: serverRef: obj * request: obj * timeout: int -> obj
    /// Log an error message.
    abstract error: msg: string -> unit
    /// Log an error message with metadata.
    abstract error: msg: string * metadata: obj -> unit

Escaping F# keywords

Use double-backtick notation for Erlang function names that are F# keywords:

[<Erase>]
type IExports =
    /// Matches objects in the table against a pattern.
    abstract ``match``: table: TableId * pattern: obj -> obj array
    /// Creates a new ETS table. (Erlang: ets:new/2)
    abstract new_: name: Atom * options: obj list -> TableId
    /// Check list membership. (Erlang: lists:member/2)
    abstract ``member``: elem: obj * list: obj -> bool

Combining both patterns

A module binding can be paired with typed [<Emit>] helpers for a better F# API:

/// Raw module binding (returns obj, caller must handle Erlang tuples)
[<Erase>]
type IExports =
    abstract read_file: filename: string -> obj
    abstract write_file: filename: string * data: obj -> obj

[<ImportAll("file")>]
let file: IExports = nativeOnly

/// Typed helper with charlist conversion and Result return
[<Emit("""
(fun() ->
    case file:read_file(binary_to_list($0)) of
        {ok, FileReadData__} -> {ok, FileReadData__};
        {error, FileReadReason__} -> {error, erlang:atom_to_binary(FileReadReason__)}
    end
end)()
""")>]
let readFile (path: string) : Result<string, string> = nativeOnly

Defining Opaque Types

Wrap Erlang opaque types in [<Erase>] single-case discriminated unions with a phantom type parameter. This gives compile-time type safety with zero runtime overhead, and the phantom lets the F# type system carry additional information across call sites (see the earlier "Opaque types" section for full guidance on phantom parameters).

/// Erlang process identifier. 'Msg captures the mailbox message type.
[<Erase>]
type Pid<'Msg> = Pid of obj

/// Erlang reference. 'Tag captures what the reference refers to.
[<Erase>]
type Ref<'Tag> = Ref of obj

/// Erlang atom. (Atom does not need a phantom — all atoms are the same kind.)
/// The constructor is private: see "Erased constructors do not convert" below.
[<Erase>]
type Atom = private Atom of obj

/// ETS table identifier. 'Key and 'Row capture the stored tuple shape.
[<Erase>]
type TableId<'Key, 'Row> = TableId of obj

For bindings that don't benefit from a phantom (the handle is truly opaque and not parameterised over anything), still wrap in a single-case [<Erase>] DU — never use a bare type X = obj alias (see the anti-patterns section):

// GOOD
[<Erase>] type Req = Req of obj

// BAD — silently admits any obj
type Req = obj

Generic erased types

For Erlang containers like maps and lists, use generic erased types to get compile-time type safety on keys, values, and elements:

/// Erlang map with typed keys and values.
[<Erase>]
type BeamMap<'K, 'V> = BeamMap of obj

/// Erlang list with typed elements.
[<Erase>]
type BeamList<'T> = BeamList of obj

Use a Beam prefix to avoid confusion with F#'s built-in Map and list types. The generics exist only at compile time — at the Erlang level these are plain maps and lists with zero overhead.

Then use the generic types throughout the interface:

[<Erase>]
type IExports =
    abstract new_: unit -> BeamMap<'K, 'V>
    abstract get: key: 'K * map: BeamMap<'K, 'V> -> 'V
    abstract put: key: 'K * value: 'V * map: BeamMap<'K, 'V> -> BeamMap<'K, 'V>
    abstract is_key: key: 'K * map: BeamMap<'K, 'V> -> bool
    abstract size: map: BeamMap<'K, 'V> -> int

Usage becomes fully typed — no box needed:

let m: BeamMap<string, int> = maps.new_ ()
let m = maps.put ("key", 42, m)
maps.get ("key", m) |> equal 42  // returns int, not obj

String and Atom Conversions

F# strings compile to Erlang binaries (<<"hello">>), not charlists. Many OTP functions expect charlists, so you need to convert:

Direction Emit code
F# string → charlist binary_to_list($0)
charlist → F# string erlang:list_to_binary(...)
F# string → atom binary_to_atom($0) or erlang:binary_to_atom($0)
atom → F# string erlang:atom_to_binary($0)

Erased constructors do not convert

Atom is an erased single-case DU ([<Erase>] type Atom = private Atom of obj), so the constructor is not a conversion — it disappears at compile time and leaves the payload exactly as it was. Writing Atom "name" would therefore produce the binary <<"name"/utf8>>, which never matches an atom-keyed term (maps:find, ets:info, a gen_server tag) and fails silently rather than loudly. That is why the constructor is private.

Build atoms through the companion module, which emits the real BIF:

Atom.ofString "name"      // erlang:binary_to_atom(<<"name"/utf8>>)  → the atom 'name'
Atom.toString someAtom    // erlang:atom_to_binary(SomeAtom)         → an F# string

Erlang.binaryToAtom / Erlang.atomToBinary are the same two BIFs bound at the erlang module level; use whichever reads better at the call site.

Atoms are never garbage collected and the atom table is bounded, so only convert names from a known, finite set — never unbounded runtime input. When the set of names is fixed and known at compile time, prefer a plain nullary DU, whose cases compile straight to atom literals with no runtime conversion at all:

type EtsTableType = Set | OrderedSet | Bag | DuplicateBag   // → set | ordered_set | ...

The same reasoning applies to every erased type: BeamChardata.ofString exists for exactly this reason, and any new [<Erase>] type whose payload needs a real conversion should hide its constructor and expose a companion module instead.

Example — an Erlang function that takes a charlist path and returns a charlist result:

[<Emit("""
(fun() ->
    case file:get_cwd() of
        {ok, FileGetCwdDir__} ->
            {ok, erlang:list_to_binary(FileGetCwdDir__)};
        {error, FileGetCwdReason__} ->
            {error, erlang:atom_to_binary(FileGetCwdReason__)}
    end
end)()
""")>]
let getCwd () : Result<string, string> = nativeOnly

Dual API: F#-friendly default + BEAM-native *Raw

Some OTP functions return a value in a BEAM-native form that is efficient and composable when you are authoring Erlang, but awkward in idiomatic F#. Two cases recur:

  • chardata (unicode:chardata()) — a binary, a charlist, or a nested iolist. Returned by string:pad/replace/reverse, uri_string:compose_query, io_lib:format, and friends. It is valid anywhere a binary or iodata is accepted (io:format, gen_tcp:send, Cowboy response bodies), so it can be passed straight on without flattening. Keeping it unflattened and flattening once at the I/O boundary is the idiomatic way to build output without repeatedly copying binaries.
  • raw Erlang lists — a plain linked list, as returned by maps:keys, string:split, re:split. F# array operations need it ref-wrapped first (see "Erlang lists vs F# arrays").

For these, bind the function twice:

  • the default (plain name) returns the F#-friendly form — a flattened string, or a ref-wrapped 'T array — because that is what an F# consumer expects to compare, store, and pattern-match;
  • a <name>Raw variant returns the BEAM-native type — BeamChardata or BeamList<'T> — for zero-copy BEAM output and interop with hand-written Erlang.
/// Pads String on the trailing side to at least Length grapheme clusters.
[<Emit("unicode:characters_to_binary(string:pad($0, $1))")>]
let pad (s: string) (length: int) : string = nativeOnly

/// Like `pad`, but returns the raw chardata without flattening. See `BeamChardata`.
[<Emit("string:pad($0, $1)")>]
let padRaw (s: string) (length: int) : BeamChardata = nativeOnly

BeamChardata (in Types.fs) is an erased unicode:chardata() with two conversions:

[<Erase>]
type BeamChardata = BeamChardata of obj

BeamChardata.ofString: string -> BeamChardata // a binary is already valid chardata (zero cost)
BeamChardata.toString: BeamChardata -> string // flatten via unicode:characters_to_binary

Guidelines:

  • The default is the flattened / F#-friendly one. Reach for *Raw only when you specifically want the native form — most callers want the default.
  • Don't invent a *Raw where the function already returns the F# type. string:lowercase / uppercase / trim / slice return a binary for binary input, so there is no raw chardata form to expose. Verify in erl before adding one.
  • The raw form is the honest return; the default's conversion is what makes its string / array signature true. A default that claims string but skips the flatten is silently wrong: string:pad("hi", 5) is [<<"hi">>,32,32,32], which compares unequal to <<"hi ">> yet prints the same — the kind of bug that only surfaces once the value is compared, matched, or stored.
  • Pick the honest native type. Only reverse yields a real charlist; pad / replace yield iodata (nested binaries and integers). BeamChardata covers all of them — do not use BeamList<char>, which would misrepresent iodata as a list of chars.

Where it applies:

Native form Default (F#) *Raw (BEAM) Applied
chardata string BeamChardata string:reverse/pad/replace, uri_string:compose_query, io_lib:format
raw list 'T array BeamList<'T> maps:keys/values/to_list, string:split, binary:split, re:split (all 4 arities), proplists:get_keys

IIFE Wrapping for Variable Scoping

As of Fable 5.0.0 this is handled automatically — the Erlang backend wraps every case-containing Emit in its own (fun() -> ... end)(), so clause variables are isolated even when the same Emit is inlined twice into one function. You do not need to add the IIFE yourself, and reusing a clause variable name across bindings no longer collides. (Verified on 5.1.0: an un-wrapped case Emit called twice in one function compiles via erlc with no "unsafe variable" error and runs correctly.)

Historically this required a manual Immediately Invoked Function Expression:

// Previously needed; now redundant — Fable adds the wrapper. Both forms generate identical
// Erlang (Fable does not double-wrap a case Emit that already carries its own IIFE).
[<Emit("case file:read_file($0) of {ok, Data} -> {ok, Data}; {error, R} -> {error, R} end")>]

Existing bindings still carry manual IIFEs; they are harmless (belt-and-suspenders) and can be dropped opportunistically. New bindings don't need them.

Variable Naming in Emit

Use descriptive, suffixed variable names in Emit expressions to avoid collisions with other generated Erlang variables. The convention is ModuleDescription__ or Description__N:

// BAD — generic names like Data, Reason risk collisions
[<Emit("case file:read_file($0) of {ok, Data} -> ... end")>]

// GOOD — descriptive suffixed names
[<Emit("case file:read_file($0) of {ok, FileReadData__} -> ... end")>]

// GOOD — numbered suffix for multiple bindings in one module
[<Emit("""
(fun() ->
    Url__0 = binary_to_list($0),
    Headers__0 = [{binary_to_list(K__0), binary_to_list(V__0)} || {K__0, V__0} <- $1],
    case httpc:request(get, {Url__0, Headers__0}, $2, []) of
        {ok, {{_, StatusCode__0, _}, _RespHeaders__0, Body__0}} ->
            {ok, #{status_code => StatusCode__0, body => erlang:list_to_binary(Body__0)}};
        {error, Reason__0} ->
            {error, erlang:list_to_binary(io_lib:format(<<"~p">>, [Reason__0]))}
    end
end)()
""")>]
let get (url: string) (headers: (string * string) list) (ssl: SslOptions) : Result<HttpResponse, string> = nativeOnly

Defining Record Types for Structured Returns

Use F# records to provide typed access to Erlang map results:

type HttpResponse =
    { StatusCode: int
      Body: string }

Then construct them in Emit using Erlang map syntax (#{field => value}), since F# records compile to Erlang maps:

#{status_code => StatusCode__0, body => erlang:list_to_binary(Body__0)}

Note: record field names are converted to snake_case in the generated Erlang.

Interop Gotchas

Erlang lists vs F# arrays

F# arrays on BEAM are ref-wrapped values in the process dictionary (via fable_utils:new_ref/1). Raw Erlang lists returned from OTP calls (e.g., ets:tab2list/1, maps:keys/1) are not ref-wrapped. This means Array.length and other F# array operations will fail on raw Erlang lists.

Solution: Use [<Emit>] on abstract members to wrap the return value with fable_utils:new_ref(), converting the Erlang list into a proper F# array. For the reverse direction, unwrap with erlang:get():

[<Erase>]
type IExports =
    /// Returns keys as an F# array (wraps Erlang list with new_ref).
    [<Emit("fable_utils:new_ref(maps:keys($0))")>]
    abstract keys: map: BeamMap<'K, 'V> -> 'K array

    /// Converts a map to key-value pairs as an F# array.
    [<Emit("fable_utils:new_ref(maps:to_list($0))")>]
    abstract to_list: map: BeamMap<'K, 'V> -> ('K * 'V) array

    /// Converts an F# array of key-value pairs to a map (unwraps ref).
    [<Emit("maps:from_list(erlang:get($0))")>]
    abstract from_list: list: ('K * 'V) array -> BeamMap<'K, 'V>

This allows standard F# array operations to work naturally:

maps.keys m |> Array.length |> equal 2       // works
maps.to_list m |> Array.map fst              // works

Note: [<Emit>] on an abstract member overrides the [<ImportAll>] code generation for that specific method — other members still get the automatic module:function(...) calls.

If you don't need F# array interop (e.g., the result stays in Erlang list land), use BeamList<'T> as the return type instead and skip the wrapping.

ImportAll functions use tupled arguments

[<ImportAll>] interface members use tupled arguments (comma-separated), not curried:

// Interface definition — tupled
abstract put: key: obj * value: obj * map: obj -> obj

// Usage — tupled call
let m = maps.put (box "key", box "value", m)

[<Emit>] bindings use curried arguments:

// Emit definition — curried
[<Emit("erlang:monitor(process, $0)")>]
let monitor (pid: Pid) : Ref = nativeOnly

// Usage — curried call
let ref = monitor child

Module-level Emit functions and cross-module calls

All public API functions that use Emit should be direct Emit bindings, not wrappers around private Emit functions. Fable compiles cross-module non-Emit calls as Erlang module calls (e.g., httpc:get/3), which won't exist in the target Erlang module.

Callbacks: plain F# function types work at any arity

Erlang funs have a fixed arity, and lists:foldl/3 applies its callback as F(Elem, Acc) — all arguments in one call. It is tempting to conclude that a curried F# callback compiles to nested 1-arity funs and needs System.Func to be uncurried. It does not. On BEAM an F# function value compiles to an Erlang fun of its remaining arity, so 'T -> 'Acc -> 'Acc becomes fun(X, Acc) -> … directly.

// Both of these generate exactly the same Erlang: lists:foldl(fun(X, Acc) -> … end, 0, L)
[<Emit("lists:foldl($0, $1, $2)")>]
let foldlCurried (f: 'T -> 'Acc -> 'Acc) (acc: 'Acc) (l: BeamList<'T>) : 'Acc = nativeOnly

[<Emit("lists:foldl($0, $1, $2)")>]
let foldlFunc (f: System.Func<'T, 'Acc, 'Acc>) (acc: 'Acc) (l: BeamList<'T>) : 'Acc = nativeOnly

Verified on Fable 5.13.0 for every form worth worrying about — lambda literal, named function, function returned from a function, curried [<Emit>] binding, tupled [<ImportAll>] interface member, and a callback passed through an obj-typed parameter. Partial application is handled the same way: foldlCurried (add3 10) 0 l emits fun(B, C) -> add3(10, B, C) end, still 2-arity.

So System.Func is a style choice, not a correctness requirement, at any arity.

Recommendation for new bindings: use plain F# function types. They read better at the call site — Decode.field key Decode.string d, not Decode.field key (System.Func<_, _> Decode.string) d. Lists, Maps, Queue and Decode all use plain function types. Logger.Filter.addPrimary is the one remaining System.Func in the bindings.

test/TestCallbacks.fs pins this contract: if a future Fable stops eta-expanding, those tests fail with badarity before any binding does.

Positional $N substitution in a curried Emit is correct regardless — including when an earlier $N is referenced after a later one in the body.

Historical note: two claims have been retired here. First, that curried Emit + raw function args caused positional-substitution swaps ({badmap, #Fun<...>} / badarity) — stale, does not reproduce on Fable 5.x. Second, that System.Func was required for multi-argument callbacks to get the right arity — wrong, as the probe above shows; the two forms are indistinguishable in the generated Erlang.

Anti-patterns

Things to avoid when writing new bindings:

Anti-pattern: type X = obj

A bare alias makes X freely interchangeable with any obj, offering no type safety at all. Always wrap in an erased DU instead:

// BAD
type Req = obj

// GOOD
[<Erase>] type Req = Req of obj

The [<Erase>] DU compiles away at runtime (zero cost) but prevents obj values from silently flowing into a Req-typed slot.

Anti-pattern: obj list as an options bag

// BAD — no type safety on what goes in the list
abstract new_: name: Atom * options: obj list -> TableId<'K, 'R>

Options lists in Erlang are usually heterogeneous but drawn from a closed set of shapes. Model the shapes explicitly. Either use a plain DU (when all options are atoms):

type EtsTableType = Set | OrderedSet | Bag | DuplicateBag
abstract new_: name: Atom * options: EtsTableType list -> TableId<'K, 'R>

Or define a marker interface plus [<Erase>] static constructors (for heterogeneous options including tuples like {keypos, N}):

type IEtsOption = interface end

/// Bare flag atoms, as nullary DU cases so they compile to atom literals.
type EtsFlag = NamedTable | Public | Protected | Private

[<Erase>]
type EtsOption =
    // `unbox "named_table"` would produce the BINARY <<"named_table">>, not an atom —
    // ets:new/2 would reject it. Go through a nullary DU case (an atom literal, free) or
    // Atom.ofString (binary_to_atom at runtime).
    static member inline named_table: IEtsOption = unbox NamedTable
    static member inline flag (f: EtsFlag): IEtsOption = unbox f
    static member inline tableType (t: EtsTableType): IEtsOption = unbox t
    static member inline keypos (n: int): IEtsOption = unbox (box (Atom.ofString "keypos", n))

Anti-pattern: obj as handler state

// BAD — state is untyped; callers lose type info across handler boundaries
let ok (req: Req) (state: obj) : obj = nativeOnly

Handler callbacks thread a user-controlled state across invocations. Make it generic:

// GOOD — 'State is inferred from caller; the handler result carries it
[<Erase>] type HandlerResult<'State> = HandlerResult of obj

[<Emit("{ok, $0, $1}")>]
let ok (req: Req) (state: 'State) : HandlerResult<'State> = nativeOnly

Anti-pattern: returning obj from an OTP call

// BAD — caller has no guidance on what the return actually is
abstract get_env: app: Atom * key: Atom -> obj

Prefer one of:

  • Dynamic if the value is genuinely unknown — caller decodes.
  • Typed Result<T, string> if the Erlang returns {ok, V} | {error, R} with an IIFE Emit wrapping.
  • Record if the return is a map with known keys.
  • T option if the return is V | undefined (IIFE converts the sentinel).

Anti-pattern: boxing at call sites just to satisfy obj

If you find yourself writing Erlang.foo (box value) routinely, the binding is under-typed. Add a generic parameter so value flows through without the box:

// BAD — forces box at every call site
let send (pid: Pid) (msg: obj) : unit = nativeOnly

// GOOD — types flow naturally
let send (pid: Pid<'Msg>) (msg: 'Msg) : unit = nativeOnly

Anti-pattern: Result<unit, _> over a bare-ok function without an Emit wrapper

Fable encodes Ok () as the tuple {ok, ok} (because unit is the atom ok) and Error e as {error, e}. So a compiled F# match ... with Ok () | Error _ expects {ok, _} / {error, _} tuples. But many OTP functions return a bare ok atom on success — logger:add_handler/3, supervisor:terminate_child/2, file:write_file/2, etc. A plain binding typed Result<unit, _> passes that bare ok straight through, where it matches neither branch:

// BAD — compiles, but every successful call is silently wrong at runtime.
// OTP returns `ok`, Fable's Ok () is `{ok, ok}`, so the match falls through.
abstract terminate_child: supRef: SupRef * id: Atom -> Result<unit, Atom>

This is a nasty latent bug because:

  • It compiles cleanly — the type is plausible and the codegen is valid Erlang.
  • The error path still matches ({error, _} is already in Result shape), so a test that only checks the failure case passes and hides the bug. The success path is the one that breaks.

Fix: bridge ok -> {ok, ok} with an [<Emit>] wrapper. This works on abstract interface members too — it just overrides codegen for that one method:

// GOOD — wrapper converts the bare ok; Error term passes through unchanged.
[<Emit("(fun() -> case supervisor:terminate_child($0, $1) of ok -> {ok, ok}; {error, TerminateChildReason__} -> {error, TerminateChildReason__} end end)()")>]
abstract terminate_child: supRef: SupRef * id: Atom -> Result<unit, Atom>

A wrapper-free Result binding is correct only when the OTP function already returns {ok, V} | {error, R} (e.g. logger:get_handler_config/1, gen_server:start_link/3) — that shape matches Fable's Result representation directly, so no [<Emit>] is needed.

Always add a test that exercises the success (ok) path of any bare-ok binding — that is the only path that reveals a missing wrapper.

Anti-pattern: asserting an implementation-defined value instead of the invariant

Some OTP functions return a value that is correct but not portable — it varies with the OTP release, how the term was constructed, or VM internals. A test that pins the exact value passes on the box it was written on and fails elsewhere. binary:referenced_byte_size/1 is the canonical example: it reports the size of the underlying memory a (sub-)binary references, which the docs call "a hint for optimization, not exact". For "hello" it returned 5 on OTP 25, 40 on OTP 27, and 256 for a shell literal.

// BAD — passes on OTP 25, fails on OTP 27. The binding is fine; the assertion isn't portable.
binary.referenced_byte_size "hello" |> equal 5

// GOOD — assert the guarantee the function actually makes: it references at least what it contains.
(binary.referenced_byte_size "hello" >= Erlang.byteSize "hello") |> equal true

When a function's exact result is implementation-defined, assert the invariant it guarantees (a bound, an ordering-independent property, a round-trip), not a value observed on one runtime. Verify in erl across OTP versions if you are unsure which part of the result is contractual.

Module File Template

/// Type bindings for Erlang <module_name> module
/// See https://www.erlang.org/doc/apps/<app>/<module_name>
module Fable.Beam.ModuleName

open Fable.Core
open Fable.Beam  // for Atom, Pid, Ref, TimerRef, Dynamic, ...

// fsharplint:disable MemberNames

// ============================================================================
// Opaque types (add phantom type parameters!)
// ============================================================================

/// Erased handle — phantom 'Tag carries additional compile-time info.
[<Erase>]
type MyHandle<'Tag> = MyHandle of obj

// ============================================================================
// Discrete atom sets (plain DUs compile to atoms)
// ============================================================================

/// Options for MyOp. Each case compiles to an Erlang atom.
type MyOpFlag =
    | Simple                                        // → atom simple
    | [<CompiledName("with_log")>] WithLog          // → atom with_log

// ============================================================================
// Raw module binding — prefer typed helpers below for new code
// ============================================================================

[<Erase>]
type IExports =
    /// Does something. Use the typed `doSomething` below in new code.
    abstract do_something: arg: 'Arg -> obj

[<ImportAll("module_name")>]
let moduleName: IExports = nativeOnly

// ============================================================================
// Typed API (Result return for {ok, V} | {error, R} shapes)
// ============================================================================

/// Note: the (fun() -> ... end)() around this case is optional as of Fable 5.0.0
/// (the backend auto-wraps case Emits for variable scoping); shown for clarity.
[<Emit("(fun() -> case module_name:do_something($0) of {ok, Result__} -> {ok, Result__}; {error, Reason__} -> {error, erlang:atom_to_binary(Reason__)} end end)()")>]
let doSomething (arg: string) : Result<string, string> = nativeOnly

// ============================================================================
// For unknown-shape returns — return Dynamic, not obj
// ============================================================================

[<Emit("module_name:get_info($0)")>]
let getInfo (arg: string) : Dynamic = nativeOnly

Complete Example: Binding a New OTP Module

Here's how you'd bind crypto (a module not yet in Fable.Beam), following every typing convention in this guide:

/// Type bindings for Erlang crypto module
/// See https://www.erlang.org/doc/apps/crypto/crypto
module Fable.Beam.Crypto

open Fable.Core
open Fable.Beam

// fsharplint:disable MemberNames

// ============================================================================
// Discrete atom sets — plain DUs compile to atoms
// ============================================================================

/// Hash algorithms. Each case is a plain atom; multi-word names use [<CompiledName>].
type HashAlg =
    | Sha
    | Sha224
    | Sha256
    | Sha384
    | Sha512
    | Sha3_256
    | Md5
    | [<CompiledName("blake2b")>] Blake2b
    | [<CompiledName("blake2s")>] Blake2s

// ============================================================================
// Raw module binding — typed helpers below are the preferred API
// ============================================================================

[<Erase>]
type IExports =
    /// Lists supported hash algorithms.
    abstract supports: ``type``: Atom -> Atom list

[<ImportAll("crypto")>]
let crypto: IExports = nativeOnly

// ============================================================================
// Typed helpers — prefer these over the raw IExports
// ============================================================================

/// Compute a message digest using the given algorithm.
[<Emit("crypto:hash($0, $1)")>]
let hash (alg: HashAlg) (data: string) : string = nativeOnly

/// Compute a SHA-256 hash of a binary string.
[<Emit("crypto:hash(sha256, $0)")>]
let sha256 (data: string) : string = nativeOnly

/// Generate N cryptographically strong random bytes as a binary.
[<Emit("crypto:strong_rand_bytes($0)")>]
let randomBytes (n: int) : string = nativeOnly

Call sites are now type-safe — a typo in the algorithm name is a compile error, and the data / result types are string (binaries) instead of obj:

let digest = Crypto.hash HashAlg.Sha256 "hello world"  // ok
let digest = Crypto.hash HashAlg.Shah256 "hello world" // COMPILE ERROR — no such case

Usage:

open Fable.Beam.Crypto

let hash = sha256 "hello world"
let bytes = randomBytes 16