Skip to content

fix(beam): compile [<StringEnum>] cases to atoms instead of binaries - #4867

Merged
dbrattli merged 1 commit into
mainfrom
fix/beam-stringenum-atoms
Aug 1, 2026
Merged

fix(beam): compile [<StringEnum>] cases to atoms instead of binaries#4867
dbrattli merged 1 commit into
mainfrom
fix/beam-stringenum-atoms

Conversation

@dbrattli

@dbrattli dbrattli commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Problem

[<StringEnum>] is the idiomatic Fable way to bind "a closed set of string-literal constants". On JS each case lowers to a string literal, which is exactly what a JS API expects. The Beam analogue of a JS string-literal constant is an atom, not a binary: the OTP functions such a binding targets — ETS table types, logger levels, transport/protocol names, gen_server name registration — pattern-match atoms and reject binaries.

Beam inherited the generic path, so every case became <<"...">> and the value was unusable against any of them:

[<StringEnum>]
type ProbeAccess =
    | Public
    | [<CompiledName("ordered_set")>] OrderedSet
probe3() ->
    {<<"public"/utf8>>, <<"ordered_set"/utf8>>,
     erlang:is_atom(<<"public"/utf8>>), erlang:is_atom(<<"ordered_set"/utf8>>)}.
%% both is_atom calls return false

A plain nullary DU already compiles to an atom on Beam, [<CompiledName>] included — [<StringEnum>] simply never reached that representation.

Fix

transformStringEnum is language-agnostic, and by the time Fable2Beam sees the value it is an ordinary string literal with no entity information left. So the fix goes where the FSharpUnionCase is still in hand: transformStringEnumCase dispatches on the language and, for Beam, emits a bare atom — quoted when the name is not valid unquoted atom syntax ([<CompiledName("Horizontal")>]'Horizontal', CaseRules.KebabCase'content-box'). Construction and the equality that pattern matching lowers to share it, so both sides stay consistent.

It lives in FSharp2Fable.fs rather than FSharp2Fable.Util.fs because Beam/Prelude.fs compiles after Util but before it, which is what makes quoteErlangAtom reachable.

The type mapping has to follow the representation. [<StringEnum>] types were hard-mapped to Fable.String, and ToString.toStringByType has Type.String -> Some arg — so string x on a variable erased to a no-op and handed an atom to code expecting a binary. A StringEnum therefore no longer maps to Fable.String on Beam either, and string x routes through fable_convert:to_string. Ordered comparison becomes declaration-order correct as a side effect, since the values now reach compare_union instead of being compared as text:

compare Public OrderedSet   // -1 (F# declaration order); was 1 (alphabetical on the binaries)

%A also now prints Public rather than "public", matching .NET.

Scope

  • [<CompiledValue(true|1|1.0)>] cases are genuine bool/int/float constants rather than tags and keep their literal. A case-level [<Emit>] still wins.
  • [<Erase>] and TypeScriptTaggedUnion are deliberately not included. An erased case with no fields keeps its binary: [<Erase>] means "no runtime representation", not "a constant tag".
  • No other target is affected. Non-Beam falls through to the untouched transformStringEnum, and the type-mapping change only adds a conjunct.

After this, [<StringEnum>] and a plain nullary DU are equivalent on Beam — the intended end state, documented in FABLE-BEAM.md, which had no recorded decision on [<StringEnum>] at all.

Tests

Five new tests in tests/Beam/InteropTests.fs, alongside the four existing StringEnum tests which pass unmodified (string value still yields the binary they assert):

  • erlang:is_atom/1 is true for a plain case, a [<CompiledName>]-renamed one, and snake/kebab-cased ones
  • the emitted atom equals a bare atom written by hand, including the two that need quoting
  • pattern matching round-trips
  • the atom is accepted by a real OTP call: ets:new(T, [Kind]) asserted back through ets:info(T, type)
  • [<CompiledValue>] bool cases stay literals

Verification

  • Beam suite: 2663 passed, 0 failed (.NET 2641 / Erlang 2663), plus entry-point program tests.
  • JavaScript suite: 3083 passed, 0 failed.
  • typeof<ProbeAccess>.FullName"QuickTest.ProbeAccess" (was System.String), with no dangling reflection call — isErasedOrStringEnumEntity handles the now-DeclaredType case.
  • Fantomas clean.

Known consequence

A tuple whose first element is a StringEnum renders under %A as a union case — (Public, OrderedSet, true, true) prints Public (OrderedSet, true, true). This is the documented tagless-representation ambiguity in fable_string.erl ("genuinely indistinguishable when a tuple's first element is itself a fieldless union case"); StringEnums now inherit it, as nullary DUs already did.

🤖 Generated with Claude Code

`[<StringEnum>]` means "a closed set of string-literal constants for
interop". On JS each case lowers to a string literal because that is what
a JS API expects; the Beam analogue is an atom, not a binary. OTP functions
that take a tag — ETS table types, `logger` levels, transport names,
`gen_server` name registration — pattern-match atoms and reject binaries,
so every StringEnum value was unusable against them:

    probe3() ->
        {<<"public"/utf8>>, <<"ordered_set"/utf8>>,
         erlang:is_atom(<<"public"/utf8>>), ...}

`transformStringEnum` is language-agnostic and by the time Fable2Beam sees
the value it is an ordinary string literal with no entity information left,
so the fix has to happen where the FSharpUnionCase is still in hand.
`transformStringEnumCase` dispatches on the language and emits a bare atom
for Beam, quoted when the name is not valid unquoted atom syntax
(`[<CompiledName("Horizontal")>]` -> 'Horizontal', kebab-case ->
'content-box'). Construction and the equality used by pattern matching
share it, so both sides stay consistent.

The type mapping has to follow the representation, or `string x` erases to
a no-op and hands an atom to code expecting a binary — so a StringEnum no
longer maps to `Fable.String` on Beam either, and `string x` routes through
`fable_convert:to_string`. Ordered comparison becomes declaration-order
correct as a side effect, since the values now reach `compare_union`
instead of being compared as text.

`[<CompiledValue>]` cases are genuine bool/int/float constants rather than
tags and keep their literal; a case-level `[<Emit>]` still wins. `[<Erase>]`
unions are deliberately not included: an erased case with no fields keeps
its binary, because `[<Erase>]` means "no runtime representation", not
"a constant tag".

After this, `[<StringEnum>]` and a plain nullary DU are equivalent on Beam.
No other target is affected: non-Beam falls through to the untouched
`transformStringEnum`, and the type-mapping change only adds a conjunct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dbrattli
dbrattli merged commit 366919a into main Aug 1, 2026
41 checks passed
@dbrattli
dbrattli deleted the fix/beam-stringenum-atoms branch August 1, 2026 08:59
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