Skip to content

fix(beam): make reflection metadata agree with record/union codegen - #4849

Merged
dbrattli merged 1 commit into
mainfrom
fix/beam-record-field-name-mangling
Jul 23, 2026
Merged

fix(beam): make reflection metadata agree with record/union codegen#4849
dbrattli merged 1 commit into
mainfrom
fix/beam-record-field-name-mangling

Conversation

@dbrattli

Copy link
Copy Markdown
Collaborator

The bug

Record construction and record reflection sanitized field names with two different functions, and they disagree for any record field whose F# name starts with a lowercase letter.

NewRecord keys the record map with sanitizeFieldName, which appends a trailing _ to lowercase-first names so firstName and FirstName can coexist as atoms. makePropertyInfo derived erl_name — documented as "the sanitized name the field is actually keyed by in the record map" — with sanitizeErlangName, which trims exactly that underscore.

type Lower = { alpha: string; beta: int }
let l = { alpha = "a"; beta = 1 }

FSharpValue.GetRecordFields (box l)                              // {badkey,alpha}
(FSharpType.GetRecordFields typeof<Lower>).[0].GetValue (box l)  // {badkey,alpha}

// silent corruption: MakeRecord "succeeds", then explodes at the first field access
let built = FSharpValue.MakeRecord(typeof<Lower>, [| box "a"; box 1 |]) |> unbox<Lower>
built.alpha                                                      // {badkey,alpha_}

The two failures point in opposite directions, which confirms the split: MakeRecord builds #{alpha => ...} (reflection's name) while the compiled accessor reads alpha_ (codegen's name). PascalCase fields are unaffected, since both functions then agree on description/count — which is why every existing reflection test, all of which use PascalCase fields, missed it.

The real-world shape that hits it on every field is a remoting-style API contract, whose fields are idiomatically camelCase:

type IServer =
    { getNumbers: unit -> Async<int list>
      greet: string -> Async<string> }

JS and Python are unaffected; only Beam.

The fix

Derive erl_name with sanitizeFieldName — the same function codegen keys the map with — and document it as the single source of truth for record-map keys so the two cannot drift again.

Two further fixes fall out of that:

  • sanitizeFieldName was missing the character cleanup that sanitizeErlangName does. A field named base' produced the atom base'_, printed as 'base'_' — invalid Erlang, which broke compilation of the existing PatternMatchTests. This was already latent for any record with such a field, since codegen keyed the map with it too. The cleanup is now factored into stripNonAtomChars and both sanitizers route through it.

  • Union erl_tag ignored [<CompiledName>]. Codegen tags a case with its CompiledName verbatim, but reflection always snake_cased the F# name, so MakeUnion built a tag no pattern match looks for. Both now go through a shared unionCaseTagName.

Secondary

FSharpValue.GetRecordFields on a value whose type is not statically known (the obj returned by MakeRecord) still cannot work — a Beam record map carries no type tag, so no TypeInfo is recoverable at runtime — but it now reports {record_type_unknown_at_compile_time, FullName} instead of an opaque {badkey,fields}. Rejecting it at compile time with a proper diagnostic is still open; Beam's Replacements.fs has no warning path today.

Tests

Seven tests added to tests/Beam/ReflectionTests.fs, covering what the PascalCase-only suite could not:

  1. GetRecordFields / PropertyInfo.GetValue / GetRecordField on a lowercase-first record.
  2. MakeRecord on that record, read back through the compiled accessor (r.alpha), not through reflection — this is what catches the silent-corruption direction.
  3. A record of Erlang keyword fields ({ end_: int; fun_: int }), to be sure checkErlKeywords still composes.
  4. A { name: string; Name: string } record, pinning that the disambiguating underscore still keeps the two apart.
  5. A camelCase record of ... -> Async<'T> fields invoked through the returned PropertyInfo — the remoting shape above.
  6. MakeUnion / GetUnionFields on a case carrying [<CompiledName>].

Verified:

  • dotnet test tests/Beam (ReflectionTests filter) — 78 passed on .NET, new tests included.
  • ./build.sh test beam2658 passed, 0 failed.

All changes are confined to src/Fable.Transforms/Beam/ and src/fable-library-beam/; no shared files touched, so no other target is affected.

🤖 Generated with Claude Code

Record construction and record reflection sanitized field names with two
different functions. `NewRecord` keys the map with `sanitizeFieldName`, which
appends a trailing `_` to lowercase-first names so `firstName` and `FirstName`
can coexist as atoms; `makePropertyInfo` derived `erl_name` with
`sanitizeErlangName`, which trims exactly that underscore. For any field whose
F# name starts lowercase, reflection read a key the constructor never wrote:

  type Lower = { alpha: string; beta: int }

  FSharpValue.GetRecordFields (box l)        // {badkey,alpha}
  FSharpValue.MakeRecord(typeof<Lower>, ...) // "succeeds", then
  built.alpha                                // {badkey,alpha_}

PascalCase fields are unaffected, since both functions then agree — which is
why the existing reflection tests never caught it.

Derive `erl_name` with `sanitizeFieldName`, the same function codegen keys the
map with, and document it as the single source of truth for record-map keys.

Two further fixes fall out of that:

- `sanitizeFieldName` was missing the character cleanup `sanitizeErlangName`
  does, so a field named `base'` produced the atom `base'_`, printed as
  `'base'_'` — invalid Erlang. Factored the cleanup into `stripNonAtomChars`
  and routed both sanitizers through it.

- Codegen tags a union case with its `[<CompiledName>]` verbatim, but
  reflection's `erl_tag` always snake_cased the F# name, so `MakeUnion` built
  a tag no pattern match looks for. Both now go through `unionCaseTagName`.

`FSharpValue.GetRecordFields` on a value whose type is not statically known
(the `obj` returned by `MakeRecord`) still cannot work — a Beam record map
carries no type tag — but it now reports `record_type_unknown_at_compile_time`
instead of an opaque `{badkey,fields}`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dbrattli
dbrattli merged commit ec6a3df into main Jul 23, 2026
41 checks passed
@dbrattli
dbrattli deleted the fix/beam-record-field-name-mangling branch July 23, 2026 20:03
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