An Erlang/BEAM target for Fable.
- Learn the BEAM/OTP platform deeply by building a compiler target (the same approach that made Fable.Python a success for learning Python)
- Bring F#'s type system, pattern matching, and computation expressions to the BEAM ecosystem
- Bring F#'s
MailboxProcessorto the BEAM as an in-process, source-compatible abstraction (so existing F# async/agent code just compiles) — note this is a same-process CPS model, not an OTP actor;MailboxProcessorturned out not to be the right surface for real process-isolated OTP actors - Provide real OTP concurrency (process-isolated actors, supervision trees, hot code
reloading) through the separate Fable.Beam
bindings library and the Fable.Actor model,
rather than overloading
MailboxProcessor
Fable.Beam requires OTP 25 or later. This is the oldest version still available
via apt install erlang on current Linux LTS distributions (Ubuntu 22.04, Debian 12,
Pop!_OS). Users should be able to install Erlang from their distro package manager
and have it work without adding third-party repos.
Key OTP features the runtime depends on:
| Feature | Minimum OTP | Used In |
|---|---|---|
Maps (#{}) |
17 | All modules |
Named funs (fun F(X) -> ...) |
17 | Generated recursive lambdas |
erlang:monotonic_time/1 |
18 | fable_stopwatch.erl |
erlang:system_time/1 |
18 | fable_date.erl |
atomics module |
21 | fable_utils.erl (byte arrays), fable_random.erl |
uri_string module |
21 | fable_uri.erl |
| JIT compiler | 24 (improved in 25) | Bit-syntax integer wrapping performance |
maybe keyword reserved |
25 | Escaped in sanitizeErlangName |
Generate .erl files (not Core Erlang, not Elixir). Rationale:
- Learning-first: reading generated Erlang output teaches the language
- OTP integration: OTP docs and patterns are written in Erlang
- Debuggable: users can read and understand the output
- Sufficient: Erlang surface syntax is regular enough for code generation
- Can always add a Core Erlang backend later if performance demands it
Same pipeline as all Fable targets:
F# Source
↓ FSharp2Fable (existing)
Fable AST
↓ FableTransforms (existing)
Fable AST (optimized)
↓ Fable2Beam
Erlang AST
↓ ErlangPrinter
.erl source files
Fable.Beam should be its own target with its own idioms. While the compiler pipeline is shared with other targets, the Replacements layer and runtime library should take full advantage of Erlang/BEAM capabilities rather than inheriting patterns from JavaScript or Python that don't fit.
Many .NET BCL operations that require complex library emulation in JS or Python map directly to Erlang built-ins:
| Area | JS/Python approach | Erlang approach |
|---|---|---|
| Integers | Fixed-width emulation (JS BigInt, Python PyO3 Rust) | Native arbitrary-precision — direct +, -, *, div, rem |
| Int64/BigInt | Library calls (big_int:op_add, etc.) |
Direct binary ops — Erlang integers ARE arbitrary-precision |
| Bitwise ops | JS routes Int64 through BigInt library | Native band, bor, bxor, bsl, bsr, bnot |
| Lists | Array-based emulation | Native linked lists — direct [H|T], lists:* |
| Maps | Library objects/dicts | Native #{} maps, maps:* |
| Sets | Library Set class | Native ordsets (sorted lists) |
| Structural equality | Util.equals() library call |
Native =:= (deep comparison on all types) |
| Structural comparison | Util.compare() library call |
Native <, >, =<, >= (works on all terms) |
| Hashing | Custom hash functions | erlang:phash2/1 |
| Pattern matching | Compiled to if/else chains | Native pattern matching in case expressions |
| Sequences | Lazy iterators | Lazy seqs via compiled seq.erl/seq2.erl |
Rule: If Erlang can do it natively, do it natively. Only create library modules
(fable-library-beam/*.erl) for operations that genuinely need helper code. Avoid
falling through to the JS Replacements fallback for Beam-specific operations.
The F# test suite represents valid F# code that must compile and run correctly on all targets. When an Erlang edge case causes a test failure:
- DO: Add a helper function in
fable-library-beam/that handles the edge case (e.g.,fable_convert:to_float/1handles"1."whichbinary_to_float/1rejects) - DO: Use
#if FABLE_COMPILERblocks for genuine cross-platform differences (e.g., .NET CultureInfo in parsing) - DON'T: Change the F# test input to avoid the edge case (e.g., changing
float("1.")tofloat("1.0")— this hides the bug)
The Replacements pipeline tries Beam.Replacements.tryCall first, then falls back to
JS.Replacements.tryCall. The JS fallback injects extra arguments (comparers, adders)
that Erlang doesn't need and generates library imports (Util, BigInt, etc.) that
don't exist in Erlang.
Handle operations in Beam Replacements to get clean, original argument lists. Reserve the JS fallback only for operations that genuinely work the same way.
| File | Purpose | Reference | Status |
|---|---|---|---|
Beam.AST.fs |
Erlang AST type definitions (intentionally minimal) | Python/Python.AST.fs |
Done |
Fable2Beam.fs |
Main Fable AST → Erlang AST transforms | Fable2Php.fs / Python |
Done |
Fable2Beam.Util.fs |
Shared helpers for the transforms | Python/Fable2Python.Util.fs |
Done |
Fable2Beam.Reflection.fs |
Compile-time reflection type-info generation | Python reflection | Done |
Replacements.fs |
.NET BCL → Erlang mappings (full implementation) | Python/Replacements.fs |
Done |
ErlangPrinter.fs |
Erlang AST → .erl source code |
Python/PythonPrinter.fs |
Done |
Prelude.fs |
Name sanitization + Erlang keyword escaping | — | Done |
Started as a single Fable2Beam.fs (PHP pattern) and has since split out
Fable2Beam.Util.fs and Fable2Beam.Reflection.fs as complexity grew (Python pattern).
The Erlang AST (Beam.AST.fs) deliberately stayed small — see "Erlang AST" below.
Erlang modules implementing F# core types:
| Module | Purpose | Notes | Status |
|---|---|---|---|
| fable_option.erl | Option | Some(x) = x, None = undefined | Done |
| fable_list.erl | FSharpList | fold, find, choose, collect, etc. | Done |
| fable_map.erl | FSharpMap | Erlang native maps, pick/try_pick/min/max | Done |
| fable_seq.erl | Seq / IEnumerable | Eager lists, delay/singleton/unfold | Done |
| fable_string.erl | String utilities | Erlang binaries, pad/replace/join, sprintf/printf/String.Format | Done |
| fable_comparison.erl | Comparison | compare/2 returning -1/0/1 | Done |
| fable_char.erl | Char utilities | is_letter/digit/upper/lower/whitespace | Done |
| fable_convert.erl | Type conversions | Robust to_float handling edge cases | Done |
| fable_reflection.erl | Reflection | Full FSharpType/FSharpValue support: TypeInfo as maps, record/union/tuple/function type tests, GetRecordFields/MakeRecord, GetUnionFields/MakeUnion, GetTupleFields/MakeTuple, PropertyInfo.GetValue | Done |
| fable_result.erl | Result | {ok, V} or {error, E} | Done |
| fable_set.erl | FSharpSet | ordsets (sorted lists), fold/map/filter/partition/union_many/intersect_many | Done |
| fable_async_builder.erl | AsyncBuilder | CPS builder operations (bind, return, delay, etc.) | Done |
| fable_async.erl | Async | High-level ops (RunSynchronously, Parallel, Sleep, etc.) | Done |
| fable_regex.erl | Regex | Wraps Erlang re module (PCRE), IsMatch/Match/Matches/Replace/Split |
Done |
| fable_resize_array.erl | ResizeArray | List manipulation helpers (set_item, remove, insert, find, sort) | Done |
| fable_dictionary.erl | Dictionary | Mutable dictionary via process dict + Erlang maps, TryGetValue with out-refs | Done |
| fable_hashset.erl | HashSet | Mutable set via process dict + Erlang maps, UnionWith/IntersectWith/ExceptWith | Done |
| fable_queue.erl | Queue | FIFO queue via process dict + Erlang queue module | Done |
| fable_stack.erl | Stack | LIFO stack via process dict + list | Done |
| fable_timespan.erl | TimeSpan | Ticks-based, create/from_/component accessors/total_/arithmetic/parse/to_string | Done |
| fable_date.erl | DateTime | 2-tuple {Ticks, Kind}, calendar module, formatting, parsing, arithmetic | Done |
| fable_date_offset.erl | DateTimeOffset | 3-tuple {Ticks, OffsetTicks, Kind}, wraps fable_date | Done |
| fable_guid.erl | Guid | UUID v4 generation, parse, toString, comparison | Done |
| fable_uri.erl | Uri | URI parsing and manipulation | Done |
| fable_utils.erl | Utilities | IEnumerator (lists/refs/maps/HashSet), apply_curried, infinity/NaN helpers, atomics byte arrays (new_byte_array, byte_array_get/set/length) | Done |
| fable_bit_converter.erl | BitConverter | Byte conversion, endianness | Done |
| fable_decimal.erl | Decimal | Fixed-scale integer (value × 10^28), multiply/divide/to_string/parse/from_parts | Done |
| fable_mailbox.erl | MailboxProcessor | In-process CPS continuation model (same as JS/Python) | Done |
| fable_cancellation.erl | CancellationToken | Process dict pattern, create/cancel/register/is_cancellation_requested, timer-based auto-cancel | Done |
| fable_stopwatch.erl | Stopwatch | StartNew, Elapsed, ElapsedMilliseconds, Stop, Reset, Restart, IsRunning, Frequency, GetTimestamp | Done |
| fable_observable.erl | Observable | subscribe, add, choose, filter, map, merge, pairwise, partition, scan, split | Done |
| fable_event.erl | Event / IEvent | trigger/publish/add, choose/filter/map/merge/pairwise/partition/scan/split | Done |
| fable_date_only.erl | DateOnly | create/components/day_number/add_*/from_date_time/to_string/parse | Done |
| fable_time_only.erl | TimeOnly | create/from_/components/ticks/add_/is_between/to_time_span/to_string/parse | Done |
| fable_parallel.erl | Array.Parallel | spawn-based parallel_map/mapi/init/iter/iteri/collect/choose/for | Done |
| fable_quotation.erl | F# Quotations (Expr) | mk_* constructors, is_* tests, evaluate, substitute, get_free_vars |
Done |
| File | Change | Status |
|---|---|---|
src/Fable.AST/Plugins.fs |
Added Beam to Language DU |
Done |
src/Fable.Compiler/Util.fs |
Added .erl file extension |
Done |
src/Fable.Compiler/ProjectCracker.fs |
Added Beam library path | Done |
src/Fable.Cli/Entry.fs |
Added --lang beam argument parsing |
Done |
src/Fable.Cli/Pipeline.fs |
Added Beam.compileFile dispatch |
Done |
src/Fable.Transforms/Replacements.Api.fs |
Beam dispatch for all 15 API functions | Done |
src/Fable.Transforms/Transforms.Util.fs |
Added Beam to getLibPath |
Done |
src/Fable.Transforms/FSharp2Fable.Util.fs |
Added Beam to isModuleValueCompiledAsFunction |
Done |
src/Fable.Transforms/Fable.Transforms.fsproj |
Added Beam files to project | Done |
| File | Change | Status |
|---|---|---|
FableLibrary/Beam.fs |
BuildFableLibraryBeam class |
Done |
Quicktest/Beam.fs |
Quicktest handler for beam | Done |
Test/Beam.fs |
Test handler (./build.sh test beam) |
Done |
Main.fs |
Added beam to quicktest + test + fable-library | Done |
Fable.Build.fsproj |
Added new Beam files | Done |
| File | Purpose | Status |
|---|---|---|
quicktest.fs |
printfn "Hello from BEAM!" |
Done |
quicktest.fsproj |
Project file referencing Fable.Core | Done |
| F# | Erlang | Notes | |
|---|---|---|---|
int, float |
integer(), float() |
Direct | |
string |
binary() |
<<"hello">> |
|
bool |
`true \ | false` | Atoms |
unit |
ok |
Atom | |
tuple |
tuple |
Direct: {A, B, C} |
|
list<T> |
list() |
Both are linked lists! | |
option<T> |
x or {some, x} \ |
undefined |
Erased or wrapped (see below) |
Result<T,E> |
`{ok, V} \ | {error, E}` | Matches Erlang idiom exactly |
| Pattern matching | Pattern matching | Both languages excel here | |
| Immutability | Immutability | Erlang is immutable by default | |
bigint |
integer() |
Erlang has native arbitrary-precision ints |
| F# | Erlang Strategy | Alternatives |
|---|---|---|
| DU cases | Tagged tuples: {some, V}, {node, L, R} |
Maps, records |
| Records | Erlang maps: #{name => <<"Dag">>} |
Erlang records (compile-time tuples) |
| Classes | Module + map (state as map, methods as functions) | Processes holding state |
| Interfaces | Dispatch maps: #{method => fun(...) -> ... end} |
Done (object expressions) |
Mutability (ref, mutable) |
Process dictionary, ETS, or process state | Agent pattern |
| Exceptions | throw/catch with tagged tuples |
Error tuples (Erlang way) |
| Generics | Erased (Erlang is dynamically typed) | — |
| Currying | Lambda wrapping (same as Python target) | — |
| Nested modules | Flat module names: My_Module_Sub |
One file per module |
| Computation expressions | Transformed at Fable AST level; async/task → CPS | — |
A class instance has two possible representations, chosen per class at construction:
- Immutable classes (no mutable instance fields, no
as self, no base-class state to merge, and whose stored interface closures / field initializers don't usethisas a value) are emitted as a self-contained map — the instance term is the state map, exactly like a non-self-referencing object expression. These are process-portable: an interface or regular method invoked from another process reads fields from the value itself, not from the constructing process's dictionary. - Mutable classes (any
let mutable/val mutableinstance field, self-reference, etc.) keep their state in the process dictionary, keyed by amake_ref(). The BEAM has no shared mutable memory across processes, so mutation is single-process by design — cross-process object sharing of mutable instances is intentionally unsupported (use actors / message passing for that).
Field reads are decoupled from the representation: fable_utils:field_get/2 (and
iface_get, inst_state, move_next, get_current, safe_dispose) accept both a map
and a ref, so call sites and runtime helpers work regardless of which form the
constructor chose. See transformClassDeclaration in Fable2Beam.fs.
This compiler/runtime implements F#'s async and agent model in-process (CPS-based),
keeping mutable process-dict state reachable. Real OTP process concurrency (gen_server,
supervision, distribution) is layered on top via the separate
Fable.Beam bindings — see
"OTP Processes, Supervision & Actors" below.
F#'s MailboxProcessor uses same-process CPS continuations (matching JS/Python targets),
NOT OTP gen_server. This design was chosen because:
- F# MailboxProcessor body closures capture mutable state from the caller — a separate Erlang process can't access process dict state from the parent
- The CPS async framework (
run_synchronously) uses process dictput/erase, requiring same-process execution - gen_server would require compile-time extraction of message handlers from an opaque async body — extremely complex for no semantic benefit
The implementation mirrors src/fable-library-py/fable_library/mailbox_processor.py.
| F# | Erlang |
|---|---|
new MailboxProcessor(body) |
fable_mailbox:default(Body) — creates agent with empty queue |
MailboxProcessor.Start(body) |
fable_mailbox:start(Body) — create + start |
agent.Start() |
fable_mailbox:start_instance(Agent) — run body via start_immediate |
inbox.Receive() |
fable_mailbox:receive_msg(Agent) — returns Async<Msg> via from_continuations |
agent.Post(msg) |
fable_mailbox:post(Agent, Msg) — queue + process_events |
agent.PostAndAsyncReply(f) |
fable_mailbox:post_and_async_reply(Agent, F) — reply channel + Async<Reply> |
replyChannel.Reply(v) |
(maps:get(reply, Channel))(V) — emitExpr inline |
OTP actors live in Fable.Beam: Real process-isolated actors (gen_server /
gen_statem, supervision, fault tolerance) are provided by the separate
Fable.Beam OTP bindings and the
Fable.Actor library — not this in-process
MailboxProcessor.
Async<T> compiles to a continuation-passing-style (CPS) function, not an Erlang
process:
Async<T> = fun(Ctx) -> ok end
Ctx = #{on_success, on_error, on_cancel, cancel_token}The function is cold — it does nothing until invoked with a context, matching F#'s
cold-async semantics. Composition (bind, return, try/with, while, for, …) just
threads new contexts through these functions; see fable_async_builder.erl.
Everything runs inline in the caller's process. This is deliberate, not a
limitation: the CPS body reaches mutable state stored in the process dictionary (mutable
let, ref cells, arrays, MailboxProcessor queues), and running in the same process keeps
that state reachable. RunSynchronously, StartImmediate, and StartWithContinuations
all execute the chain in the current process — no spawn, no trampoline (Erlang has
native TCO).
| F# | Erlang (fable_async / fable_async_builder) |
|---|---|
async { return x } |
fun(Ctx) -> (maps:get(on_success, Ctx))(X) end |
let! x = comp / do! |
bind(Comp, fun(X) -> ... end) — CPS monadic bind |
return / return! |
return/1 / return_from/1 |
try/with, try/finally |
try_with/2, try_finally/2 (CPS on_error/compensation + Erlang try/catch) |
while / for |
while/2 / for/2 (recursive bind) |
Async.StartImmediate |
start_immediate/1 — inline, default context (fire-and-forget) |
Async.RunSynchronously |
run_synchronously/1 — inline, result stashed via a process-dict ref |
Async.StartWithContinuations |
start_with_continuations/4 — inline with caller continuations |
Async.Sleep |
timer:sleep(Ms) inline; with a cancel token, receive waits on a timer/cancel message (still same process) |
Async.Sequential |
run each computation inline via run_synchronously |
Async.Catch |
catch_async/1 — wraps result in {choice1_of2,_} / {choice2_of2,_} |
Async.Ignore |
bind + return ok |
Async.FromContinuations |
from_continuations/1 — lower-level CPS primitive |
task { ... } |
alias for async (Task is an alias for Async on Beam) |
task.Result |
fable_async:run_synchronously(Comp) |
The one exception — Async.Parallel spawns. To get real parallelism it spawns one
process per child computation and collects results in order via message passing
(fable_async:parallel/1). Each child runs run_synchronously in its own process, so
parallel children do not share the parent's process-dict state. This is the only
place the async runtime leaves the caller's process.
Cancellation (fable_cancellation.erl) is also in-process: a token is a make_ref()
keying a process-dict map #{cancelled, listeners, next_id}. Async.Sleep is the only
operation that observes it cooperatively (via the receive path above).
Real BEAM concurrency — spawning processes, gen_server, supervisor, supervision
trees, applications, ETS, distribution — is not part of this compiler or runtime
library. It lives in the separate
Fable.Beam bindings library, which provides
typed F# bindings to OTP modules (Fable.Beam.GenServer, Fable.Beam.Supervisor,
Fable.Beam.Application, Fable.Beam.Erlang, Fable.Beam.Ets, …) plus an actor model
(Fable.Actor).
The compiler's job is only to emit correct Erlang; OTP behaviours are opt-in F# bindings
layered on top. Speculative OTP API design (attributes, supervisor { } CEs, direct
gen_server interop, etc.) belongs in the Fable.Beam repo, not here.
Get the full pipeline working end-to-end with minimal features.
- Add
Language.Beamto the DU - Minimal Erlang AST (module, function, expression, literal)
- Minimal Fable2Beam (constants,
printfn, simple functions) - Minimal ErlangPrinter (output valid
.erl) - CLI integration (
--lang beam/--lang erlang) - Compile and run:
printfn "Hello from BEAM!"
Goal: dotnet fable --lang beam produces a .erl file that erlc compiles and runs.
What works now: String/int/float/bool literals, tuples, lists, printfn, let bindings,
sequential expressions, type casts, curried apply, emit expressions. Unhandled Fable
expressions produce todo_* atom placeholders. The printfn chain goes through
printf → toConsole → io:format.
How to test:
dotnet build src/Fable.Cli
dotnet run --project src/Fable.Cli --no-launch-profile -- \
--cwd src/quicktest-beam src/quicktest-beam/quicktest.fsproj \
--lang beam --outDir /tmp/beam-out --noCacheNote: Phase 1 quicktest command above is for manual exploration. For the full
automated test suite, use ./build.sh test beam (see Phase 2).
Core F# language features that map naturally to Erlang. All implemented in
Fable2Beam.fs with corresponding AST additions and printer updates.
- Arithmetic operators (
+,-,*,div//,rem) with int/float distinction - Comparison operators (
=:=,=/=,<,=<,>,>=) - Bitwise operators (
band,bor,bxor,bsl,bsr,bnot) - Logical operators (
andalso,orelse,not) - Exponentiation via
math:pow/2 - If/else →
case Guard of true -> Then; false -> Else end - Lambda (single arg, curried) →
fun(Arg) -> Body end - Delegate (multi-arg, uncurried) →
fun(A, B) -> Body end - CurriedApply →
Applynode for calling fun values - Test expressions (
UnionCaseTest,ListTest,OptionTest) - Get expressions (
TupleIndex,UnionTag,UnionField,ListHead,ListTail,OptionValue,FieldGet,ExprGet) - DecisionTree / DecisionTreeSuccess (following JS target pattern)
- NewList fix →
ListCons([H | T]instead of[H, T]) - NewUnion → atom-tagged tuples
{atom_tag, Field1, Field2, ...}, bare atoms for fieldless cases - NewOption → value or
undefinedatom - Set expressions (ValueSet → variable rebind, FieldSet →
maps:put) - LetRec → sequential fun assignments
- AST additions:
ListCons,ApplyonErlExpr;PListonErlPattern - Printer:
BinOpparenthesization,UnaryOpword operator spacing
What works now: Most basic F# programs compile to valid Erlang. Operators, conditionals, functions (named, anonymous, higher-order, partial application), pattern matching (integers, strings, booleans, DUs, options, lists, tuples), decision trees, and let/letrec bindings all produce correct Erlang output.
Test suite: tests/Beam/ with xUnit. Run with ./build.sh test beam which:
- Runs all tests on .NET via
dotnet test - Compiles tests to
.erlvia Fable (library files auto-copied tofable_modules/fable-library-beam/) - Compiles library
.erlfiles infable_modules/fable-library-beam/witherlc - Compiles test
.erlfiles witherlc -pa fable_modules/fable-library-beam - Runs an Erlang test runner (
erl_test_runner.erl) with-pa fable_modules/fable-library-beamthat discovers and executes alltest_-prefixed functions
The Erlang test runner discovers and runs every test_-prefixed arity-1 function. The
suite currently has 2446 passing tests across 63 test files — more than the Python
target. Coverage spans the F# core library and language features:
- Collections: Seq, List, Array (incl. byte arrays via
atomics), Map, Set, ResizeArray, Dictionary, HashSet, Queue, Stack - Primitives & text: arithmetic (incl. Int64, BigInt, decimal), bitwise/logical/ comparison, string, char, regex, conversions, encoding
- Types: records, unions, tuples, anonymous records, enums, classes/interfaces, object expressions, units of measure, structural equality/comparison, reflection
- Date/time: DateTime, DateTimeOffset, DateOnly, TimeOnly, TimeSpan, Guid, Uri, Stopwatch
- Control flow & effects: pattern matching, active patterns, loops, exceptions, type testing, tail calls, async/task, MailboxProcessor, cancellation, observables/events
- Interop:
emitErl,Import,ImportAll+Eraseinterfaces, module calls - Integration: a Sudoku solver and a raytracer demo
See tests/Beam/ for the individual test files.
F#'s defining feature on BEAM. DU basics (construction and pattern matching via DecisionTree) were implemented in Phase 2. This phase adds records and structural equality.
- DU declaration → tagged tuple constructors (Phase 2)
- DU pattern matching → clause matching on tagged tuples (Phase 2)
- Records → Erlang maps (
#{field => value}) - Record update syntax → F# compiler decomposes into
NewRecord+FieldGet(works automatically) - Anonymous records → Erlang maps (field names provided inline)
- Structural equality for DUs and records → Erlang's native
=:=(deep comparison)
Design decisions:
- Records map to Erlang maps (
#{name => <<"Alice">>, age => 30}) - Field names converted to snake_case atoms
FieldGet→maps:get(field, Map),FieldSet→maps:put(field, Value, Map)- Structural equality uses Erlang's native
=:=operator (deep comparison for all types: tuples, maps, lists, atoms, numbers, binaries) — no runtime library needed. Implemented viaBeam/Replacements.fsinterceptingGenericEquality/op_Equalitybefore JS Replacements generatesUtil.equalslibrary calls.
-
list<T>→ Erlang lists (cons cells — natural fit) - List module functions →
lists:module calls +fable_list.erllibrary -
array<T>→ process dict refs wrapping Erlang lists (mutable viaput/get); byte arrays useatomicsfor O(1) read/write -
Map<K,V>→ Erlang native#{}maps,maps:module calls +fable_map.erl -
Set<T>→ Erlangordsets(sorted lists),ordsets:module calls +fable_set.erl -
Seq<T>→ eager Erlang lists withfable_seq.erllibrary -
fable-library-beamruntime:fable_list.erl,fable_map.erl,fable_string.erl,fable_option.erl,fable_seq.erl - Range expressions:
[1..n]→lists:seq(1, n),[1..2..n]→lists:seq(1, n, 2) - Array indexing:
arr.[i]→lists:nth(i + 1, arr)(0-based to 1-based) - Array comprehensions:
[| for i in 0..n -> expr |]via Seq desugaring
Design decisions:
- Sequences use lazy evaluation via Fable-compiled
seq.erl/seq2.erlmodules (compiled fromSeq.fs/Seq2.fs). List-backed operations delegate tofable_list.erlBIFs. - Seq operations intercepted in Beam Replacements (not JS fallback) to avoid injected comparers/adders that Erlang doesn't need.
- Complex operations in
fable_list.erl/fable_seq.erl, simple BIF mappings viaemitExpr. - Scalar Seq operations (Fold, Reduce, Find, etc.) routed through compiled
seq.erl(Fable-compiled fromSeq.fs) viaHelper.LibCallwithSignatureArgTypes. This enables theuncurrySendingArgsFableTransform to automatically convert curried callbacks to uncurried Delegates, matching the pattern used by the Python target. - BIF qualification:
ErlangPrinter.fsautomatically prefixes known BIFs (length,hd,tl,element,put,get, etc.) witherlang:inCall(None, ...)nodes. This prevents shadowing when compiled library modules (likeseq.erl) define functions with the same name as BIFs. - Integration tested with a Sudoku solver (SudokuTests.fs) using Seq, Array, ranges, and array comprehensions.
- Sets use Erlang's
ordsetsmodule (sorted lists). Maintains ordering compatible with F#'s structural comparison. Simple operations (add,contains,union, etc.) map directly toordsets:*BIFs. Higher-order operations (fold,map,filter) usefable_set.erlfor curried function handling. Set+/-operators intercepted in BeamoperatorsviaBuiltin(FSharpSet _)arg type matching.set [1;2;3]handled viaCreateSet→ordsets:from_list.
- F# modules → Erlang modules (one
.erlper file) - Nested modules → flattened into the enclosing file's Erlang module via qualified
member names (incl. private, deeply-nested, and shadowed modules — see
MiscTests.fs) - Module function calls →
module:function(args)syntax - Import resolution and path handling
- Export lists (
-export([...])) - Snake_case output filenames (matching Erlang module name convention)
- Function name sanitization (
$XXXXhex sequences from F# backtick names) - Cross-module call resolution (derive module from
importInfo.Path) - Inline
assertEqual/assertNotEqualassertions (no util dependency needed) -
fable_modules/fable-library-beam/output structure (aligned with JS/Dart/Rust targets)
- try/with → try/catch with
erlang:errorfor exceptions -
failwith→erlang:error(<<"message">>) - Exception message access via
#{message => Reason}map wrapping - Nested try/catch works
-
Result<T,E>integration with Erlang{ok,V}/{error,E}convention - Custom F# exception types (
exception MyError of string) → maps withexn_typetag - Exception type discrimination in catch:
maps:get(exn_type, X, undefined) =:= type_name - Multi-field exceptions:
exception MyError2 of code: int * message: string - Exception
.Messageproperty viamessagefield in exception map
Extend type system support for common F# patterns.
- Enum support — F# enums are just integers in Erlang (trivial, works out of box)
- Enum declaration, construction, pattern matching — all native
int↔ enum conversion,enum<MyEnum>(n)— TypeCast is erased- Enum comparison, flags (bitwise) — native Erlang operators
EnumOfValue/EnumToValue— TypeCast is erased
- Custom exceptions —
exception MyError of string→ maps withexn_typeatom tag- Exception construction via
NewRecordaddsexn_typeandmessagefields - Pattern matching in try/catch:
maps:get(exn_type, X, undefined) =:= type_name - Exception
.Messageproperty viamessagefield in exception map - TryCatch handler preserves exception maps (is_map check), wraps non-maps in
#{message => ...} - Multi-field exceptions with named fields work correctly
- Exception construction via
- Type testing (
:?) — runtime type checks via Erlang guardsmatch x with :? int as i -> ...→is_integer(X)guard- Primitive types:
is_binary(string),is_boolean(bool),is_float(float),is_integer(int) - Collection types:
is_list(list/array),is_tuple(tuple),is_map(record/class) - Exception types:
is_map(X) andalso maps:get(exn_type, X, undefined) =:= type_name box/unboxare erased (TypeCast)
- String interpolation fix —
fable_string:to_string/1for generic value formatting- Replaces
~pformat (which showed<<"...">>for binaries) with runtime type dispatch - Handles binary/integer/float/atom natively, falls back to
~pfor complex terms
- Replaces
- Curry expressions — uses
Replacements.Api.curryExprAtRuntimeto generate nested lambdas at compile time (no runtime module needed)
CPS (Continuation-Passing Style) implementation. Async<T> = fun(Ctx) -> ok end where
Ctx = #{on_success, on_error, on_cancel, cancel_token}. CPS naturally gives cold semantics
(F# Async is cold — doesn't execute until started). Task CE is an alias for Async on the Beam
target since Erlang has no equivalent of .NET's hot Task distinction.
-
async { }computation expression → CPS builder viafable_async_builder.erl -
let!/do!→bind/2(monadic bind — run computation, pass result to binder) -
return/return!→return/1/return_from/1 -
try/within async →try_with/2(CPS on_error override + synchronous try/catch) -
while/forin async →while/2/for/2(recursive bind) -
Async.RunSynchronously→ runs in same process (preserves process dict access) -
Async.StartImmediate→ runs with default context (fire-and-forget) -
Async.Parallel→ spawn one process per computation, collect via message passing -
Async.Sleep→timer:sleep/1 -
Async.Ignore→ bind + return unit -
Async.StartWithContinuations→ direct CPS invocation -
Async.FromContinuations→ lower-level CPS primitive -
task { }computation expression → alias for async builder -
task.Result→fable_async:run_synchronously - Cancellation tokens → process dict pattern with
fable_cancellation.erl
Design decisions:
- CPS over spawn:
Async<T>is a functionfun(Ctx) -> ok end, not a spawned process. CPS naturally gives cold semantics matching F# Async. No trampoline needed — Erlang has native tail call optimization. - Everything runs inline in the caller's process —
RunSynchronously,StartImmediate,StartWithContinuations, andSequentialneverspawn. The only exception isAsync.Parallel, which spawns one process per child for real parallelism (children runrun_synchronouslyin their own process and don't share the parent's process-dict state). - RunSynchronously runs in same process: Uses a process-dict ref to store the result, NOT
spawn+receive. This preserves mutable variable (process dict) access from the async body. - Task = Async alias: Task CE builder methods route to
fable_async_builder, Task instance methods (.Result,.GetAwaiter().GetResult()) route torun_synchronously. - try_with dual handler: Both the CPS
on_erroroverride AND thetry/catchmust invoke the Handler — synchronous throws (likeerlang:error) bypass the CPS on_error path. - Erlang function naming:
return,for,whileare NOT reserved words in Erlang — they work as function names in remote calls (fable_async_builder:return(V)).
In-process CPS continuation model (same pattern as JS/Python targets). Uses process dict
for mutable state, fable_async:from_continuations for the receive/reply coordination.
-
MailboxProcessor.Start→fable_mailbox:start(Body)(create + start_immediate) -
new MailboxProcessor(body)→fable_mailbox:default(Body)(create only) -
agent.Start()→fable_mailbox:start_instance(Agent) -
inbox.Receive()→fable_mailbox:receive_msg(Agent)(Async via from_continuations) -
agent.Post(msg)→fable_mailbox:post(Agent, Msg)(queue + process_events) -
agent.PostAndAsyncReply(f)→fable_mailbox:post_and_async_reply(Agent, F) -
replyChannel.Reply(v)→(maps:get(reply, Channel))(V)(emitExpr inline)
Design decisions:
- Same-process, not gen_server: MailboxProcessor body closures capture mutable state via process dict. A separate process can't access this state. The CPS model runs everything inline in the caller's process, matching F# semantics exactly.
- State as process dict map: Agent =
#{ref => Ref}whereRefkeys into process dict storing#{body, messages, continuation}. Mutable queue + continuation slot. - Synchronous reply coordination:
post_and_async_replystores a reply callback in the reply channel map. Since everything runs synchronously via CPS, by the timepostreturns the inbox has processed the message and called Reply, so the value is available immediately. - Named
receive_msg: Erlang'sreceiveis a reserved keyword, so the function is namedreceive_msg. The Replacements dispatch maps"Receive"→receive_msg. - OTP actors live in Fable.Beam: Process-isolated actors and supervision are provided by the separate Fable.Beam OTP bindings and Fable.Actor — not this in-process MailboxProcessor.
OTP integration (supervision trees, application behaviour, hot code reloading,
distribution / multi-node) is out of scope for the compiler. It is provided by the
separate Fable.Beam bindings library
(gen_server, supervisor, application, ets, erlang process BIFs, …) and the
Fable.Actor actor model. The compiler only
needs to emit correct Erlang that those bindings can call.
- Build integration:
rebar3project generation —Main.fslays out files undersrc/(rebar3 convention), generates the rootrebar.config, and per-dependencysrc/<app>.app.src+rebar.config. Fable-generated configs are regenerated; user-owned ones are detected and left untouched. - Test suite (
tests/Beam/— 2446 tests passing,./build.sh test beam) - Erlang test runner (
tests/Beam/erl_test_runner.erl— discovers and runs alltest_-prefixed arity-1 functions) -
erlccompilation step in build pipeline (per-file with graceful failure) - Quicktest setup (
src/quicktest-beam/,Fable.Build/Quicktest/Beam.fs) - Documentation
Beam.AST.fs is deliberately small (~80 lines) and that proved sufficient for the entire
test suite. Operators are plain strings (not typed DUs), if lowers to case, and there
is no dedicated ListComprehension / BinaryExpr / MapUpdate node — those F#
constructs are expressed with the existing nodes plus Emit as a raw-Erlang escape hatch.
The actual node set: ErlLiteral; ErlPattern (PVar/PLiteral/PTuple/PList/
PWildcard); ErlExpr (literals, variables, tuples, lists/ListCons, maps, Call/
Apply, Fun/NamedFun, Case, Match, Block, BinOp/UnaryOp, TryCatch,
Emit, Receive); attributes; function defs; and modules.
The richer AST below was the original aspirational design. It was never needed and is kept only as a reference for what a fuller Erlang AST could look like if a future feature (e.g. native list comprehensions or bit-syntax literals) ever warrants it:
module rec Fable.AST.Beam
type Atom = Atom of string
type Literal =
| Integer of int64
| Float of float
| StringLit of string // binary literal <<"...">>
| AtomLit of Atom
| BoolLit of bool
| NilLit // empty list []
type Pattern =
| PVar of string
| PLiteral of Literal
| PTuple of Pattern list
| PList of Pattern list * Pattern option // [H|T] pattern
| PCons of Pattern * Pattern
| PWildcard // _
| PMap of (Pattern * Pattern) list
type Guard = Expression list // guard sequences
type Expression =
| Literal of Literal
| Variable of string
| Tuple of Expression list
| List of Expression list * Expression option // [H|T]
| Map of (Expression * Expression) list
| MapUpdate of Expression * (Expression * Expression) list
| BinOp of BinaryOp * Expression * Expression
| UnaryOp of UnaryOp * Expression
| Call of module_: Expression option * func: Expression * args: Expression list
| Fun of FunClause list // fun(Args) -> Body end
| Case of Expression * CaseClause list
| If of IfClause list
| Receive of CaseClause list * Timeout option
| Try of body: Expression list * catch_: CatchClause list * after_: Expression list
| Block of Expression list // begin ... end
| Match of Pattern * Expression // Pattern = Expr
| ListComprehension of Expression * Qualifier list
| BinaryExpr of BinaryElement list // <<"hello">>
and CaseClause = { Pattern: Pattern; Guard: Guard; Body: Expression list }
and IfClause = { Guard: Guard; Body: Expression list }
and CatchClause = { Class: Atom option; Pattern: Pattern; Guard: Guard; Body: Expression list }
and FunClause = { Patterns: Pattern list; Guard: Guard; Body: Expression list }
and Timeout = { Duration: Expression; Body: Expression list }
type BinaryOp = Add | Sub | Mul | Div | IntDiv | Rem | Band | Bor | Bxor | Bsl | Bsr | And | Or | Andalso | Orelse | Append | Subtract
type UnaryOp = Not | Bnot | UAdd | USub
type ComparisonOp = Eq | NotEq | Lt | LtE | Gt | GtE | ExactEq | ExactNotEq
type Qualifier =
| Generator of Pattern * Expression // X <- List
| BinaryGenerator of Pattern * Expression // <<X>> <= Binary
| Filter of Expression
type BinaryElement = { Value: Expression; Size: Expression option; TypeSpecifiers: Atom list }
type Attribute =
| Module of Atom
| Export of (Atom * int) list // function/arity pairs
| Import of Atom * (Atom * int) list
| Behaviour of Atom
| TypeSpec of name: Atom * spec: string // -spec
| CustomAttr of Atom * Expression list
type FunctionDef =
{ Name: Atom
Arity: int
Clauses: FunClause list }
type Form =
| Attribute of Attribute
| Function of FunctionDef
| Comment of string
type Module =
{ Name: Atom
Forms: Form list }Status: not yet implemented. Erlang's native arbitrary-precision integers make
int,int64, andbigintwork out of the box, so the test suite passes today without fixed-width wrapping. True sized-integer overflow semantics (int8/int16/int32and unsigned wrapping) are not implemented yet — the rest of this section is the design plan for when they are. Strategy A (bit-syntax wrapping) remains the recommendation.
.NET has fixed-width integers (int8, int16, int32, int64, uint8...uint64)
with specific overflow/wrapping behavior. Erlang, like Python, has arbitrary-precision
integers — no overflow, no fixed bit width.
For Fable.Python this required reimplementing all sized integer types in Rust via
PyO3 (src/fable-library-py/src/ints.rs, ~1200 lines) using wrapping_add,
wrapping_sub, wrapping_mul, etc. Plus typed arrays in Rust for the same reason.
This was a major effort.
Erlang has a feature Python lacks — binary pattern matching with bit-level type specifications. This can express wrapping semantics in pure Erlang:
%% Wrapping int32 arithmetic — pure Erlang, no NIF needed
-module(fable_int32).
-export([add/2, sub/2, mul/2, from_int/1]).
add(A, B) -> wrap32(A + B).
sub(A, B) -> wrap32(A - B).
mul(A, B) -> wrap32(A * B).
from_int(N) -> wrap32(N).
wrap32(N) ->
<<V:32/signed-integer>> = <<N:32/signed-integer>>,
V.
%% Same pattern for all widths:
%% wrap8(N) -> <<V:8/signed-integer>> = <<N:8/signed-integer>>, V.
%% wrap16(N) -> <<V:16/signed-integer>> = <<N:16/signed-integer>>, V.
%% wrap64(N) -> <<V:64/signed-integer>> = <<N:64/signed-integer>>, V.
%% uwrap32(N)-> <<V:32/unsigned-integer>> = <<N:32/unsigned-integer>>, V.How it works:
<<N:32/signed-integer>>— constructs a 32-bit binary, truncating to low 32 bits<<V:32/signed-integer>> =— pattern-matches it back, interpreting as signed- Result: correct two's complement wrapping, same as .NET
The BEAM JIT compiler (OTP 24+) optimizes binary operations heavily — this is one of Erlang's most performance-critical paths (telecom/protocol workloads).
| Strategy | Effort | Performance | Correctness |
|---|---|---|---|
| A. Bit syntax wrapping (pure Erlang) | Low | Good (JIT-optimized) | Exact |
| B. Band + sign extension (pure Erlang) | Low | Good | Exact |
| C. Rust NIF (like Python) | High | Best | Exact |
| D. Wrap at boundaries only | Low | Best | Risky |
Recommendation: Strategy A (bit syntax) for initial implementation.
- No NIF compilation needed — pure Erlang, trivial to deploy
- Correct by construction (bit truncation + signed reinterpretation)
- If profiling shows it's a bottleneck, can move to NIF later
- Much simpler than the Python/PyO3 approach (~50 lines vs ~1200 lines)
Strategy B alternative using band:
%% int32 via bitwise masking
wrap32(N) ->
R = N band 16#FFFFFFFF,
case R >= 16#80000000 of
true -> R - 16#100000000;
false -> R
end.Both A and B are pure Erlang. A is more idiomatic and arguably clearer.
Two approaches for where to wrap:
Wrap every operation (safe, slower):
%% F#: let x = a + b * c
X = fable_int32:add(A, fable_int32:mul(B, C))Wrap at assignment only (faster, still correct for most code):
%% F#: let x = a + b * c
X = fable_int32:wrap(A + B * C)The second is valid when intermediate overflow doesn't cross the 64-bit boundary (extremely rare in practice). Start with wrap-every-op for correctness, optimize later with a compiler flag if needed.
.NET integer parsing (Int32.Parse, Int32.TryParse) with NumberStyles support
needs implementation. In Erlang:
parse_int32(Str) ->
N = binary_to_integer(Str),
case N >= -2147483648 andalso N =< 2147483647 of
true -> {ok, N};
false -> {error, overflow}
end.
parse_int32(Str, 16) ->
N = binary_to_integer(Str, 16),
wrap32(N).For Python, arrays also required Rust/PyO3 typed storage (Int32Array,
Float64Array, etc.) because Python lists box every element.
Erlang is better here:
- Erlang tuples — fixed-size, O(1) element access via
element(Index, Tuple), but immutable (copy-on-update viasetelement/3) - Erlang
arraymodule — functional sparse arrays, O(log n) access, good for large mutable-style arrays - ETS tables — true mutable storage, O(1) access, but heavier setup
atomicsmodule (OTP 21+) — mutable integer arrays in shared memory, excellent forint32[]/int64[]use cases
Suggested approach:
- Small/read-heavy arrays → tuples (fast reads, copies on write)
- General case →
arraymodule (functional updates, good enough perf) - Hot-path mutable int arrays →
atomics(true O(1) mutable access) - No Rust NIF needed — Erlang's built-in options cover the use cases
| Concern | Python Solution | Erlang Solution |
|---|---|---|
| Sized integers | Rust/PyO3 (~1200 lines) | Bit syntax (~50 lines pure Erlang) |
| Integer parsing | Rust/PyO3 (~100 lines) | binary_to_integer + bounds check |
| Typed arrays | Rust/PyO3 typed storage | Tuples / array module / atomics |
| Deployment | Needs Rust toolchain + compilation | Pure Erlang, no external deps |
This is a significant advantage of targeting BEAM over Python. The bit syntax alone eliminates the single hardest piece of the Fable.Python runtime.
-
Strings: Erlang binaries (
<<"hello">>) — modern convention, more efficient -
Module naming: Snake_case derived from filename (
MyModule.fs→my_module) -
Replacements strategy: Beam has its own dispatch in
Replacements.Api.fswith JS fallback (Beam.Replacements.tryCall→JS.Replacements.tryCallifNone). Beam handles equality, comparison, numerics, collections, and conversions natively; only operations that genuinely work the same way fall through to JS. All 15Replacements.Api.fsfunctions now have explicit Beam dispatch —errorreturns the message directly (wrapped bymakeThrow),defaultofreturns type-appropriate zero values, and ref cell operations use the process dictionary (make_ref+put/get). -
File structure: Single
Fable2Beam.fsfor Phase 1 (PHP pattern), split later as complexity grows (Python pattern) -
DU representation: Atom-tagged tuples
{atom_tag, Field1, ...}for cases with fields, bare atoms for fieldless cases. Tag names derived viasanitizeErlangName(snake_case).UnionCaseTestguards non-fieldless checks withis_tupleto handle mixed DUs.UnionTagusescase is_atom(X)to dispatch between bare atoms and tuples. -
If/else: Uses
case Guard of true -> ...; false -> ... endrather than Erlang's limitedif(which only supports guard expressions) -
Division:
divfor integer types,/for float — determined by Fable's type information at compile time -
Comparison operators: Erlang's exact equality (
=:=,=/=) rather than structural (==,/=), matching F#'s value equality semantics -
DecisionTree: Follows the JS (Babel) target pattern — inline targets with Let bindings, producing nested case expressions
-
Test framework: xUnit with
[<Fact>]attributes (matching Python/Rust targets), conditional compilation viaUtil.fsfor future BEAM-side test execution -
Records: Erlang maps (
#{field => value}), field names as snake_case atoms. Field access viamaps:get/2, field update viamaps:put/3. -
Structural equality: Erlang's native
=:=for all types (no runtime library). Intercepted inBeam/Replacements.fsbefore JS Replacements generatesUtil.equalslibrary calls. Works because=:=does deep comparison on tuples, maps, and lists. -
Function name sanitization:
sanitizeErlangNameinPrelude.fsdecodes$XXXXhex sequences from F# compiled names (e.g.$0020→ space), strips apostrophes, converts to snake_case, collapses/trims underscores, and escapes Erlang reserved words (e.g.maybe→maybe_,receive→receive_). The keyword escaping usescheckErlKeywordsagainst the full OTP 25+ keyword set. Example:test$0020infix$0020add$0020can$0020be$0020generated→test_infix_add_can_be_generated -
Output filenames: Snake_case, following the Python target pattern. Uses
Naming.applyCaseRule Core.CaseRules.SnakeCaseinPipeline.fsBeam module. Erlang requires module name to match filename, soArithmeticTests.fs→arithmetic_tests.erlwith-module(arithmetic_tests). -
Library output layout: Aligned with JS/Dart/Rust targets — library files go to
fable_modules/fable-library-beam/under the output directory, not mixed with compiled project files.ProjectCracker.fsuses non-emptybuildDirto trigger the standardcopyDirmechanism.getOutPathinMain.fschecksNaming.isInFableModulesto preserve the subdirectory structure for library files while keeping project files flat inoutDir. Erlang resolves modules via code path (-pa fable_modules/fable-library-beam) rather than hierarchical imports. Third-party project output structure:output/my_module.erl+output/fable_modules/fable-library-beam/{fable_list,fable_string,seq,...}.erl. -
Inline assertions:
assertEqual/assertNotEqual(and theirTesting_equal/Testing_notEqualvariants) are inlined ascase Actual =:= Expected of true -> ok; false -> erlang:error({assert_equal, Expected, Actual}) end— no runtime dependency on a util module. -
Unit parameters: Erlang unused variable warnings suppressed by prefixing unit parameters with
_viatoErlangVarinFable2Beam.fs. -
discardUnitArg / dropUnitCallArg: Symmetric unit stripping matching JS/Python/Dart.
discardUnitArgstrips trailing unit parameters from function definitions (Lambda, Delegate, ObjectExpr members, MemberDeclaration, class methods/constructors).dropUnitCallArgstrips the corresponding unit argument at call sites (intransformCall). Both sides must be stripped symmetrically so Erlang arity matching works. The old workaround of appendingValue(UnitConstant)inbclTypewas removed. -
Block hoisting: When
Letbindings produceBlock [Match(...); body]expressions, these Blocks are invalid inside Erlang argument positions (Call, Apply, BinOp). TheextractBlock/hoistBlocksFromArgs/wrapWithHoistedhelpers extract leading assignments and hoist them before the enclosing expression. This fixes match-in-expression patterns likematch x with ... |> equal "abc". -
Recursive lambdas: Self-recursive
let rec f x = ... f (x-1) ...inside function bodies generates Erlang named funs:fun F(X) -> ... F(X-1) ... end(OTP 17+). Detected viacontainsIdentRefwhich checks if a lambda body references its own binding ident. Mutual recursion (let rec ... and ...) is supported by bundling the group into a single named fun dispatched by atom tag (MutualRecBindings). -
String interpolation:
$"text: {value}"generatesiolist_to_binary([<<"text: ">>, integer_to_binary(Value)]). Integer values useinteger_to_binary/1, string values pass through, other types useio_lib:format("~p", [Value]). Note:io_lib:formatneeds a charlist format string, not a binary — usesbinary_to_list(<<"~p">>)to convert. String concatenation from Fable's Replacements (string:concat) is intercepted and replaced withiolist_to_binary([A, B])sincestring:concatreturns charlists, not binaries.to_stringconversion lives infable_string:to_string/1. -
Assert temp variables: Complex expressions in assertEqual/assertNotEqual are stored in temp variables (
Assert_actual_N,Assert_expected_N) to avoid duplicate evaluation and Erlang "unsafe variable" errors from variable bindings inside case branches that get duplicated in error messages. -
Option representation:
None=undefinedatom. SimpleSome(x)is erased (justx). Nested options (Option<Option<T>>),GenericParam, andAnytypes use wrapped representation:Some(x)={some, x}. This avoids ambiguity whenSome(None)would otherwise be indistinguishable fromNone. Runtime smart constructorfable_option:some/1handles wrapping at generic call sites. Unlike JS/Python,Unitdoes NOT need wrapping because Erlang'sokatom is distinct fromundefined. -
Object expressions / Interfaces:
{ new IFoo with member _.Bar(x) = ... }compiles to an Erlang map of closures:#{bar => fun(X) -> ... end}. Property getters are stored as evaluated values (not closures) since call sites useGet(obj, FieldGet(name))→maps:get(name, Obj). Interface method calls use(maps:get(method, Obj))(Args). Detection:transformCall'sGet(calleeExpr, FieldGet, _, _)branch checks ifcalleeExpr.Typeis aDeclaredTypewithentity.IsInterface. Self-referencing members (e.g.,member x2.Test(i) = x2.Value - i) are supported: when any member references itsthisarg, the object is built behind a process-dict ref (ObjRef_N = make_ref(), aliased to each self-ident,putthe closure map, return the ref) so closures can reach the object under construction. -
ImportAll + Erase interface:
[<ImportAll("module")>]+[<Erase>]interface pattern for typed FFI bindings.myModule.someMethod(args)→module:some_method(Args). Detected in bothtransformCall(method calls) andtransformGet(property access) by matchingcalleeExprasImportwithSelector = "*". Emits direct Erlang remote calls instead offable_utils:iface_getdispatch. Method names are converted viasanitizeErlangName(camelCase → snake_case). Same pattern as JS/Python but with:call syntax instead of attribute access. -
Async/Task CE: CPS (Continuation-Passing Style) implementation.
Async<T>is a functionfun(Ctx) -> ok endwith context map#{on_success, on_error, on_cancel, cancel_token}. No trampoline needed (Erlang has native TCO).RunSynchronouslyruns in same process (not spawned) to preserve process dict access for mutable variables.Parallelspawns one process per computation and collects results via message passing. Task CE is an alias — Task builder methods route tofable_async_builder,.Resultroutes torun_synchronously. Replacements routing:FSharpAsyncBuilder/AsyncActivation→asyncBuilder;FSharpAsync/AsyncPrimitives→asyncs;TaskBuilder/TaskBuilderBase→taskBuilder;Task/Task<T>→tasks.DefaultAsyncBuilderin operators →fable_async_builder:singleton. -
NewArray block hoisting:
NewArray(ArrayValues ...)useshoistBlocksFromArgs+wrapWithHoistedto hoist Let bindings out of array literal positions, matching the pattern used for Call/Apply/BinOp arguments. -
sprintf / printfn / String.Format: Full F# format string support via
fable_string.erlruntime.printf/1parses format strings (%d,%s,%.2f,%g,%x, etc.) into a continuation-based format object#{input, cont}.to_text(sprintf),to_console(printfn),to_console_error(eprintfn),to_fail(failwithf) apply continuations with appropriate handlers. Multi-arity overloads (to_text/1..5) handle Fable's inlined arg passing whereCurriedApplyflattens curried args into a single call.format/2handles .NETString.Format("{0} {1}", args)with positional placeholders. Replacements routing:fsFormatfunction handlesPrintfFormat.ctor(→printf),PrintFormatToString(→to_text),PrintFormatLine/PrintFormat(→to_console), etc. Dispatched from bothoperators(forExtraTopLevelOperators.sprintf) andtryCall(forPrintfModule/PrintfFormatentities). The oldtoConsole→io:format("~s~n")hack in Fable2Beam.fs was removed. -
MailboxProcessor: In-process CPS continuation model, NOT gen_server. Agent state lives in process dict keyed by
make_ref():#{body, messages, continuation}.receive_msgusesfrom_continuationsto store OnSuccess as pending continuation;postadds to queue and callsprocess_events;post_and_async_replycreates a reply channel map#{reply => Fun}where Fun stores the reply value in process dict, and the synchronous CPS execution guarantees the value is available whenpostreturns.Replyis dispatched viaemitExpras(maps:get(reply, $0))($1). Replacements routeFSharpMailboxProcessorandFSharpAsyncReplyChannelto themailboxhandler. -
CurriedApply: Uses a simple
List.foldapplying args one at a time:cleanArgs |> List.fold (fun fn arg -> Apply(fn, [arg])) cleanApplied. This matches JS (Fable2Babel.fs) and Python (Fable2Python.Transforms.fs). Never merge CurriedApply args into Call nodes — that causes badarity errors when calling curried closures. -
Erlang keyword escaping:
sanitizeErlangNamepipes throughcheckErlKeywordsat the end to append_suffix to Erlang reserved words (e.g.maybe→maybe_,receive→receive_). TheerlKeywordsset covers OTP 25+ keywords includingmaybeandelse. This is needed because F# identifiers likemaybeare valid but would generate Erlang syntax errors. -
Arrays as process-dict refs: All non-byte arrays are now process-dict refs (
make_ref()+put/get), enabling cross-function mutation.NewArraywraps withfable_utils:new_ref([...]).derefArr/wrapArrhelpers in Replacements convert between refs and plain lists for bulk operations. Binary comparison operators on arrays usefable_comparison:compare(A, B) op 0. TypeTest for arrays usesis_reference. When an array literal flows directly into an FFI/Emit binding that derefs its argument (e.g.maps:from_list(erlang:get($0))), the naive output is...erlang:get(fable_utils:new_ref([...]))— a pointless process-dict round-trip on an immutable literal (it also leaks an un-erased process-dict entry).simplifyArrayRefDerefsinFable2Beam.fscancels it on the Beam AST when building anEmitnode: if an argument is an inlinenew_ref(list)and every occurrence of its$Nplaceholder in the macro template is deref-wrapped (erlang:get($N)/get($N)), the deref is dropped from the template and the underlying list is passed directly. Gating on the argument's AST shape (rather than parsing rendered Erlang) keeps it robust; a ref-typed variable (a bound/mutable array) keeps its deref. -
Byte arrays via atomics: Byte arrays (
Array<byte>) use Erlang'satomicsmodule for true O(1) mutable read/write. Represented as{byte_array, Size, AtomicsRef}tuples. Runtime helpers infable_utils.erlhandle both direct tuples and process-dict ref-wrapped byte arrays:byte_array_get/2,byte_array_set/3,byte_array_length/1.Array.zeroCreate<byte>usesnew_byte_array_zeroed(atomics are zero by default).Array.create n vusesnew_byte_array_filled(avoids intermediate list allocation). This enabled a 2048x2048 raytracer demo to run at ~40s on BEAM with no hand-patches. -
Mutable variable ref erasure: Process dict refs for mutable
letbindings are erased at scope exit (erase(Key)) to prevent leaks. TheEraseMutableRefsexpression is emitted at the end of function bodies and appended to Block expressions containing mutable bindings. -
Stray ok atom removal: The ErlangPrinter strips bare
okatoms (F# unit values) from non-final positions in all body contexts: top-level functions, fun/named-fun clauses, case clauses, try/catch bodies, and block expressions. Matches bothLiteral(AtomLit "ok")andEmit("ok", [])forms (the latter fromSystem.Object..ctor). -
CancellationToken: Process dict pattern with
make_ref()storing#{cancelled, listeners, next_id}. Supportscreate,cancel,cancel_after(timer-based),register/unregister,is_cancellation_requested(drains cancel messages from mailbox). Sleep integration usesreceiveinstead oftimer:sleepwhen token is present. -
Stopwatch: Runtime
fable_stopwatch.erlusingerlang:monotonic_time(microsecond). SupportsStartNew,Start,Stop,Reset,Restart,Elapsed,ElapsedMilliseconds,IsRunning,Frequency,GetTimestamp. -
FSharp.Reflection: Full runtime support via
fable_reflection.erl+ compile-time type info generation inFable2Beam.Reflection.fs. TypeInfo = Erlang map withfullname,generics, and optionalfields(records) orcases(unions). PropertyInfo/CaseInfo are maps withname,typ,tag,fields. Reflection functions renamed to avoid Erlang BIF clashes:is_tuple→is_tuple_type,is_function→is_function_type. Union tag matching uses atom tags viaerl_tagfield (matching Beam's{atom_tag, Field1, Field2}representation).MakeRecord/MakeUnionhandle both plain lists and process-dict ref arrays.GetRecordFieldsresolves concrete types throughTypeCastwrappers viagetConcreteTypehelper. -
Async error wrapping:
wrap_error/1infable_async.erlnormalizes raw errors so.Messageaccessor works: raw binaries (fromfailwith) →#{message => Bin}, maps (fromraise (exn ...)) pass through, refs pass through, everything else → formatted map. Used incatch_async,try_with, andtry_finally.catch_asyncuses atom tags{choice1_of2, V}/{choice2_of2, wrap_error(E)}matching Beam's Choice union representation. -
OperationCanceledException: Added to the exception type pattern in Beam Replacements alongside
BuiltinSystemExceptionandKeyNotFoundException. -
Module-level mutable variables:
let mutable x = vat module level is routed through the process dictionary (same mechanism as local mutableletbindings). The declaration emits amain/0fragment that initialises the value (put(x, v)); reads of the ident emitget(x)and writes (x <- e) emitput(x, e)(see theIdentExpr/Setbranches and theMemberDeclarationvalue case inFable2Beam.fs). Allmain/0fragments — mutable inits, snapshot inits (below), anddoactions — are merged in declaration order, so module initialisation runs as a single ordered sequence, mirroring F#.- Snapshotting immutable values that read a mutable: an immutable module value whose
initializer reads a module-level mutable (e.g.
let c = topA) must capture the value at binding time, because F# evaluates module bindings once, in order, before any later reassignment. Compiled naively as a lazy 0-arity accessor it would re-read the live process-dict value and observe later writes. Such values are therefore also eagerly initialised inmain/0(put(c, <value>), emitted in declaration order so it captures the mutable's value at that point) plus an accessor that reads the snapshot (c() -> get(c)). Detection isreadsFreeMutable, which walks the Fable body tracking locally bound names and triggers only on a free (module-level) mutable reference — self-contained bodies whose only mutables are local stay lazy, so they don't gain a spurious dependency onmain/0. - Module init runs before tests: the Erlang test runner (
erl_test_runner.erl) callstest_*/0functions directly and would never runmain/0, so module-level initialisation (mutable inits anddoactions) would never execute and reads would returnundefined. The runner now invokes each module'smain/0(if exported) before its tests, mirroring .NET module initialisation (which runs before any module code). This is safe because Beam test modules contain no top-level side effects beyond these initialisers.
- Snapshotting immutable values that read a mutable: an immutable module value whose
initializer reads a module-level mutable (e.g.
Module-level mutables live in the process dictionary and are initialised by main/0.
That makes their semantics correct only when main/0 actually runs in the process that later
reads the state:
- Works: entry-point programs (the
main/0Fable emits is the program entry — quicktest, real apps) and the test harness (now callsmain/0before each module's tests). - Does not work: a library module whose functions are called by other code without that
module's
main/0having run — its module-level mutables/snapshots readundefined. Reads from a different process than the one that ranmain/0also seeundefined, since the process dictionary is process-local.
This matches the broader Beam design (mutation is single-process by design; see "Class instance representation" and "Mutable Collections" above), but it means module-level mutable global state is not a fully general feature. Follow-up options if true cross-process / load-time module state is ever needed:
persistent_term+-on_loadfor global, load-time initialisation (reads visible from any process). Downside: writes are global-GC-heavy and meant for write-rarely data, so frequently-mutated module values would be slow.- ETS table per module for shared mutable state — O(1) writes, but cross-process shared state, against the isolation model.
Neither is implemented; the current per-process approach is the right default for typical F# programs where module-level mutables are entry-point/program state.
Currently, Dictionary, HashSet, ResizeArray, and Array use the process dictionary
pattern: make_ref() + put(Ref, EntireCollection) / get(Ref). Every mutation replaces
the entire map or list — adding one key to a 10,000-entry Dictionary copies all 10,000
entries (O(N) per mutation).
ETS (Erlang Term Storage) is an alternative that provides O(1) in-place mutable tables. However, the current process dict approach is arguably the better design for BEAM:
- Process isolation preserved: Mutations stay within a single process, matching the BEAM philosophy of isolated processes with no shared mutable state
- No accidental sharing: ETS tables are cross-process by default, which would break the isolation model that makes BEAM reliable
- Simple cleanup:
erase(Ref)at scope exit vs explicitets:delete(Tab)with no finalizers to ensure cleanup - Small collections are fine: Most F# code uses small-to-medium collections where the full-copy overhead is negligible
| Aspect | Process Dict (current) | ETS |
|---|---|---|
| Read | O(1) get(Ref) + O(1) maps:get |
O(1) ets:lookup |
| Write | O(N) full map copy via put |
O(1) ets:insert |
| Process isolation | Yes (process-local) | No (shared by default) |
| Small collections | Fast (no table overhead) | Slower (table creation cost) |
| Large collections | Slow (full copy per mutation) | Fast (in-place mutation) |
| Cleanup | erase(Ref) |
ets:delete(Tab) (must be explicit) |
Conclusion: The process dict approach is the right default. ETS could be offered as an opt-in optimization (e.g., via an attribute) for specific cases where large collection mutation performance is critical, but it should not be the default since it introduces shared mutable state — exactly what BEAM is designed to avoid.
Records vs Maps: Erlang records are compile-time tuples (fast, but rigid). Maps are dynamic (flexible, slower). For F# records, maps seem more natural.Decided: Erlang maps. Field names as snake_case atoms,maps:get/2for access,maps:put/3for update. Structural equality via native=:=.- OTP project structure: Generate a full OTP application structure with
rebar3? Or just standalone.erlfiles initially? Interop: How should F# code call existing Erlang/Elixir libraries? Fable.Core attributes likeDecided: Three interop mechanisms: (1)[<Import("lists", "map")>]?[<Import("func", "module")>]for individual function imports →module:func(Args), (2)[<Emit("erlang:expr($0)")>]for inline Erlang expressions, (3)[<ImportAll("module")>]+[<Erase>]interface for typed module bindings →module:method(Args). The ImportAll pattern mirrors JS/Python but emits Erlang remote calls instead of attribute access.Testing: Use EUnit, Common Test, or just assert in generated code?Decided: xUnit with[<Fact>]on .NET side,Fable.Core.Testing.Assertwhen compiled to BEAM. Same pattern as Python/Rust targets.- Integer wrapping granularity: Wrap every arithmetic operation, or only at let-binding boundaries? Former is safer, latter is faster.
Function name encoding: Erlang atom names from double-backtick F# names currently produce URL-encoded names (e.g.Decided:test$0020_add). Need to decide on a cleaner encoding or stick with snake_case conversion.sanitizeErlangNamedecodes$XXXXhex sequences, strips apostrophes, converts to snake_case, collapses underscores. See "Decisions Made" above.
- Caramel — OCaml → Erlang compiler (abandoned, but useful reference for ML-to-BEAM type mappings): https://github.com/AbstractMachinesLab/caramel
- Gleam — Typed functional language on BEAM, compiles to Erlang. Study its compiler for BEAM code generation patterns: https://github.com/gleam-lang/gleam
- LFE (Lisp Flavored Erlang) — another language targeting BEAM, shows OTP integration from non-Erlang language: https://github.com/lfe/lfe
- Fable.Python — the direct template for this work. Same architecture, similar challenges (dynamic target language, no classes for DUs, module-per-file)