How to write F# bindings for Erlang/OTP modules using Fable's BEAM backend.
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: Avoidobj" below. Use generics, phantom types, DUs, records, orDynamicinstead. - 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(), useAtomnotobj) - 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, soSystem.Func<>is never required
| 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 |
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:
- A concrete F# type —
int,string,bool,Atom,unit - A generic type parameter —
'Args,'Msg,'Key,'Valuewhen callers pick the type - A phantom-typed opaque handle —
Pid<'Msg>,Ref<'Tag>,TableId<'K, 'R>,ServerRef<'Call, 'Cast> - A plain F# DU for discrete atom sets — compiles directly to atoms (see "DU cases compile to atoms")
- A record for structured Erlang maps — compiles to
#{field => value}with atom keys - An erased DU
[<Erase>] type X = X of objfor opaque tagged wrappers (e.g.,WsFrame,ServerName) U2<A, B>/U3<A, B, C>for parameters or returns that accept multiple known typesDynamic+Decodecombinators 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.
| 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 |
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.
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 = nativeOnlyErlang 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 = nativeOnlyWhen 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 arrayFor [<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> = nativeOnlyMap 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 = nativeOnlyThis 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> = nativeOnlyNote: 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.
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.
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 objThen 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 = nativeOnlyFor 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> = nativeOnlyKey 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>).
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 exs1024sUse 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_bagVerified: [<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.
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" msgAvailable 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.
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 = nativeOnlyNote: 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> -> 'AccUsage — 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.
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 xOr 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> -> objThe 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.
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-ins —
phash2(Term, Range) -> int,exactEqualsalready fall back toobjor a free generic because any term is valid; wrapping every call site inDynamicwould 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
IExportsescape hatches — when the main API is a typed helper and the rawIExportsis 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? →
Dynamicand 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.
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)
Use [<Emit>] for Erlang BIFs, operators, and any expression that should
generate inline Erlang code. Parameters are referenced with $0, $1, etc.
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/// 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/// 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 = nativeOnlyErlang 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> = nativeOnlyWhen 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>] also works for constant values:
/// Disable certificate verification (for development only).
[<Emit("[{ssl, [{verify, verify_none}]}]")>]
let verifyNone: SslOptions = nativeOnlyUse 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")>].
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 = nativeOnlyUsage:
timer.sleep 1000
let ms = timer.seconds 30 // 30000Erlang 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 -> unitUse 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 -> boolA 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> = nativeOnlyWrap 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 objFor 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 = objFor 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 objUse 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> -> intUsage 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 objF# 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) |
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# stringErlang.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> = nativeOnlySome 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 bystring: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>Rawvariant returns the BEAM-native type —BeamChardataorBeamList<'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 = nativeOnlyBeamChardata (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_binaryGuidelines:
- The default is the flattened / F#-friendly one. Reach for
*Rawonly when you specifically want the native form — most callers want the default. - Don't invent a
*Rawwhere the function already returns the F# type.string:lowercase/uppercase/trim/slicereturn a binary for binary input, so there is no raw chardata form to expose. Verify inerlbefore adding one. - The raw form is the honest return; the default's conversion is what makes its
string/arraysignature true. A default that claimsstringbut 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
reverseyields a real charlist;pad/replaceyield iodata (nested binaries and integers).BeamChardatacovers all of them — do not useBeamList<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 |
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.
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> = nativeOnlyUse 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.
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 // worksNote: [<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>] 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 childAll 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.
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 = nativeOnlyVerified 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, thatSystem.Funcwas 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.
Things to avoid when writing new bindings:
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 objThe [<Erase>] DU compiles away at runtime (zero cost) but prevents obj values from
silently flowing into a Req-typed slot.
// 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))// BAD — state is untyped; callers lose type info across handler boundaries
let ok (req: Req) (state: obj) : obj = nativeOnlyHandler 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// BAD — caller has no guidance on what the return actually is
abstract get_env: app: Atom * key: Atom -> objPrefer one of:
Dynamicif 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 optionif the return isV | undefined(IIFE converts the sentinel).
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 = nativeOnlyFable 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.
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 trueWhen 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.
/// 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 = nativeOnlyHere'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 = nativeOnlyCall 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 caseUsage:
open Fable.Beam.Crypto
let hash = sha256 "hello world"
let bytes = randomBytes 16