Skip to content

feat(beam): dual-API bindings — BeamChardata, io_lib, and raw-list variants#128

Merged
dbrattli merged 6 commits into
mainfrom
feat/beam-chardata
Jul 18, 2026
Merged

feat(beam): dual-API bindings — BeamChardata, io_lib, and raw-list variants#128
dbrattli merged 6 commits into
mainfrom
feat/beam-chardata

Conversation

@dbrattli

@dbrattli dbrattli commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #123. Establishes a dual-API convention for bindings whose OTP function returns a
BEAM-native form (chardata, or a raw Erlang list) that is efficient for authoring Erlang but awkward
in idiomatic F#, and applies it across the library.

The convention (written up in BINDINGS-GUIDE.md): bind such a function twice — an F#-friendly
default under the plain name (a flattened string, or a ref-wrapped 'T array), and a <name>Raw
variant returning the BEAM-native type (BeamChardata or BeamList<'T>) for zero-copy output and
interop with hand-written Erlang.

BeamChardata

An erased unicode:chardata(), in the BeamList/BeamMap family (Types.fs):

[<Erase>] type BeamChardata = BeamChardata of obj
BeamChardata.ofString : string -> BeamChardata   // a binary is already valid chardata (zero cost)
BeamChardata.toString : BeamChardata -> string   // flatten via unicode:characters_to_binary

Not BeamList<char>, as first sketched: only reverse yields a charlist; pad/replace yield
iodata (nested binaries and integers), which BeamList<char> would misrepresent. One honest chardata
type covers all of them.

What's bound dual-API

Native form Default (F#) *Raw (BEAM) Functions
chardata string BeamChardata string:reverse/pad/padDir/padWith/replaceFirst/replaceAll, uri_string:compose_query, io_lib:format (new)
raw list 'T array BeamList<'T> maps:keys/values/to_list, string:split×2, binary:split×2, re:split×4, proplists:get_keys

io_lib is a new module (IoLib.fs) — io_lib:format is the canonical build-a-string-from-a-template
function and returns chardata, so it gets format (→ string) + formatRaw (→ BeamChardata).

The existing default bindings are unchanged; every *Raw is additive.

Also fixes (from #123's aftermath)

Supersedes the commented-out reverse/pad ImportAll stubs: they returned chardata and never could
be ImportAll members, but are fully bound now (string + raw), so the stubs became one-line pointers.

Small structural change

BeamList (in Lists.fs) was compiled after Maps.fs, so Maps couldn't reference it. Lists and
Maps are mutually independent, so Lists.fs now compiles first and Maps.fs opens it (as do
String.fs/Re.fs).

Verification

just test    # 380 passed, 0 failed  (+13: raw round-trips, native-form equality, io_lib)
just spike   # 11 passed, exit 0

Every *Raw list test asserts the native form matches its array member (length / structural
equality); every chardata *Raw test asserts an isList proof it's unflattened plus a
toString round-trip back to the default.

🤖 Generated with Claude Code

dbrattli and others added 3 commits July 18, 2026 09:17
Fable.Beam is increasingly used to author OTP code, not just call into it
(cf. fable-compiler/Fable#4771, pinning a module's Erlang name for OTP
behaviours). In that world the chardata the OTP string/uri_string modules
return is a feature, not an impurity: it is valid anywhere a binary or iodata
is accepted (io:format, gen_tcp:send, Cowboy bodies), and keeping intermediate
results unflattened is the idiomatic way to build output and flatten once at
the I/O boundary.

Add BeamChardata (erased unicode:chardata(), in the BeamList/BeamMap family)
with ofString/toString, and a `*Raw` variant of every string function that
returns chardata: reverseRaw, padRaw, padDirRaw, padWithRaw, replaceFirstRaw,
replaceAllRaw, composeQueryRaw. The default string-returning functions are
unchanged; the Raw variants skip the unicode:characters_to_binary flatten and
hand back BeamChardata.

This supersedes the commented-out ImportAll stubs left in String.fs: reverse
and pad returned chardata and so never could be ImportAll members, but they
are fully bound now (string form + raw form), so the stubs become one-line
pointers to the working functions.

Not BeamList<char>, as first sketched: only reverse yields a charlist; pad and
replace yield iodata (nested binaries and integers), which BeamList<char>
would misrepresent. One honest chardata type covers all of them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nvention

Write down the convention BeamChardata introduces: when an OTP function returns
a BEAM-native form (chardata, or a raw Erlang list) that is efficient for
authoring Erlang but awkward in F#, bind it twice — a flattened/ref-wrapped
default under the plain name, and a `*Raw` variant returning the native type
(BeamChardata / BeamList<'T>).

Includes a "where it applies" table: the chardata axis is done (string:reverse/
pad/replace, uri_string:compose_query); the raw-list axis (maps:keys/values/
to_list, string/binary/re:split, proplists:get_keys) and io_lib:format are
identified candidates, not yet implemented. Also cross-referenced from the
Quick Reference, the F#->Erlang type table, and the type-choice decision tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the BINDINGS-GUIDE "Dual API" candidates surfaced in the survey.

io_lib (new module IoLib.fs): io_lib:format is the canonical build-a-string-
from-a-template function and returns chardata. Bound as `format` (-> string,
flattened) + `formatRaw` (-> BeamChardata), mirroring the string module.

Raw-list variants: every binding that ref-wraps a native Erlang list into an
F# array now also offers a `*Raw` returning the native BeamList<'T>, for
BEAM-side use without the new_ref round-trip:
  maps:      keysRaw, valuesRaw, toListRaw
  string:    splitFirstRaw, splitAllRaw
  binary:    splitFirstRaw, splitAllRaw
  re:        splitRaw, splitWithRaw, splitMPRaw, splitPartsRaw
  proplists: getKeysRaw

The default array-returning members are unchanged.

BeamList lives in Lists.fs, which the fsproj compiled *after* Maps.fs, so Maps
could not reference it. Lists and Maps are mutually independent (no circular
use), so Lists.fs now compiles first; Maps.fs opens Fable.Beam.Lists. String.fs
and Re.fs likewise gain the open.

+13 tests (380 total): raw lists assert the native form matches the array
member (length / structural equality), and io_lib asserts the format/formatRaw
round-trip. Guide's "where it applies" table updated to reflect what's done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dbrattli dbrattli changed the title feat(beam): BeamChardata type + raw chardata variants for authoring OTP output feat(beam): dual-API bindings — BeamChardata, io_lib, and raw-list variants Jul 18, 2026
dbrattli and others added 3 commits July 18, 2026 09:46
Every bug BROKEN-BINDINGS.md documented is fixed and merged, so a file named
"broken bindings" that lists nothing broken is stale and misleading. The
forensic account stays in git history and the #123 writeup.

Its one reusable lesson not already in the guide — assert the invariant a
function guarantees, not an implementation-defined value that varies by OTP
release (the binary:referenced_byte_size 5-on-25 / 40-on-27 case) — is now an
anti-pattern in BINDINGS-GUIDE.md. The chardata bug class it described is
already covered by the Dual-API section; that section no longer links the
deleted file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The old comments claimed these "can't be ImportAll members" because they
return chardata. That is only true for a `string` return (ImportAll codegen
can't insert the unicode:characters_to_binary flatten). A BeamChardata-typed
ImportAll member needs no conversion and would compile fine.

State the real reason: the string form needs a flatten, so it is a module
function; the raw form is exposed as `*Raw` to keep raw variants uniform
across the library, not because an interface member is impossible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Captures the concern raised in #128 review: each binding module exposes up to
three surfaces — the ImportAll interface (xxx.*, tupled), module helpers
(curried), and the *Raw variants. The *Raw layer is justified; the wart is the
arbitrary interface-vs-module split, driven by whether a return needs an Emit
wrapper rather than by anything a caller sees. Repo-wide, predates the dual-API
work.

Records options (unify onto curried module functions / onto the interface /
document only), recommends the curried-module direction prototyped on String
first, and notes the breaking, repo-wide impact (Cowboy + synapse). Hand-off
for a follow-up session; no code changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dbrattli
dbrattli merged commit fadf7d8 into main Jul 18, 2026
2 checks passed
@dbrattli
dbrattli deleted the feat/beam-chardata branch July 18, 2026 08:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant