fix(beam): compile [<StringEnum>] cases to atoms instead of binaries - #4867
Merged
Conversation
`[<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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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,loggerlevels, transport/protocol names,gen_servername 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:A plain nullary DU already compiles to an atom on Beam,
[<CompiledName>]included —[<StringEnum>]simply never reached that representation.Fix
transformStringEnumis language-agnostic, and by the timeFable2Beamsees the value it is an ordinary string literal with no entity information left. So the fix goes where theFSharpUnionCaseis still in hand:transformStringEnumCasedispatches 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.fsrather thanFSharp2Fable.Util.fsbecauseBeam/Prelude.fscompiles after Util but before it, which is what makesquoteErlangAtomreachable.The type mapping has to follow the representation.
[<StringEnum>]types were hard-mapped toFable.String, andToString.toStringByTypehasType.String -> Some arg— sostring xon a variable erased to a no-op and handed an atom to code expecting a binary. A StringEnum therefore no longer maps toFable.Stringon Beam either, andstring xroutes throughfable_convert:to_string. Ordered comparison becomes declaration-order correct as a side effect, since the values now reachcompare_unioninstead of being compared as text:compare Public OrderedSet // -1 (F# declaration order); was 1 (alphabetical on the binaries)%Aalso now printsPublicrather 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>]andTypeScriptTaggedUnionare deliberately not included. An erased case with no fields keeps its binary:[<Erase>]means "no runtime representation", not "a constant tag".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 inFABLE-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 valuestill yields the binary they assert):erlang:is_atom/1is true for a plain case, a[<CompiledName>]-renamed one, and snake/kebab-cased onesets:new(T, [Kind])asserted back throughets:info(T, type)[<CompiledValue>]bool cases stay literalsVerification
typeof<ProbeAccess>.FullName→"QuickTest.ProbeAccess"(wasSystem.String), with no dangling reflection call —isErasedOrStringEnumEntityhandles the now-DeclaredTypecase.Known consequence
A tuple whose first element is a StringEnum renders under
%Aas a union case —(Public, OrderedSet, true, true)printsPublic (OrderedSet, true, true). This is the documented tagless-representation ambiguity infable_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