Skip to content

fix(beam): make F# reflection work on the Beam target#4766

Merged
dbrattli merged 4 commits into
mainfrom
fix/beam-reflection
Jul 13, 2026
Merged

fix(beam): make F# reflection work on the Beam target#4766
dbrattli merged 4 commits into
mainfrom
fix/beam-reflection

Conversation

@dbrattli

@dbrattli dbrattli commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Brings the Beam target's reflection support up to parity with JS/Python, so that reflection-based code (typeof<'T>, FSharp.Reflection) compiles and runs.

Found while adding a Beam target to a FSharp.Reflection-based generic-value generator, which already works on .NET, JS, TS and Python. There were five distinct gaps.

1. Compiler stack-overflowed on recursive types (the blocker)

transformTypeInfo inlined a declared type's fields/cases structurally, with no by-name indirection and no cycle guard, so reflecting over a recursive type recursed forever and the compiler died:

type Tree =
    | Leaf of int
    | Branch of Tree * Tree   // self-referential

let t = typeof<Tree>          // Stack overflow, exit 134

Beam now emits a per-entity reflection function for each record/union (tree_reflection/0, pair_1_reflection/1, …) and transformTypeInfo emits a call to it rather than inlining — a remote call (module:fn(...)) when the entity lives in another file. That by-name indirection is what lets a recursive type refer to itself, and it matches what Fable2Babel and Fable2Python already do.

The indirection alone only moves the infinite recursion from compile time to run time, so — mirroring the lazy fields/cases thunks in fable-library-ts — the fields and cases are emitted as zero-arity funs, and fable_reflection forces them at the points that consume them:

tree_reflection() ->
    #{fullname => <<"Tree">>, generics => [], cases => fun() ->
        [..., #{tag => 1, name => <<"Branch">>, erl_tag => branch,
                fields => [#{name => <<"Item1">>, erl_name => item1,
                             property_type => tree_reflection()}, ...]}]
end}.

Types with no source file (BCL/.dll) have no generated module to call into, so they keep the inline path, now with a recursion guard.

2–5. Missing entries in Beam/Replacements.fs

Each of these is already supported on JS/Python:

  • FSharpReflectionExtensions — the allowAccessToPrivateRepresentation overloads resolve to static extensions (FSharpType.IsUnion.Static). They now route onto the same fable_reflection primitives, ignoring the flag as JS does. fsharpType/fsharpValue are extracted so both entities share them. (The old handlers forwarded args verbatim, so these overloads' extra argument would have produced an arity error.)
  • Enums — enums are Number(kind, IsEnum ent) in the Fable AST, not DeclaredType, so they were being erased to a bare numeric type info. The type info now carries enum_cases and the underlying type, plus Type.IsEnum, Enum.GetValues/GetNames/GetName/Parse/TryParse/IsDefined/GetUnderlyingType. Also adds Array.GetEnumerator, needed to iterate the non-generic array Enum.GetValues returns.
  • Type.MakeGenericType / Activator.CreateInstance — portable code guards .NET-only reflection behind Compiler.isDotnet, and while the branch is dead on Fable it still has to survive the replacement stage, which runs before DCE.
  • DecimalMinValue/MaxValue (both the property and the ILFieldGet path) and op_Explicit conversions to and from decimal.

Also fixed along the way

  • FSharpValue.GetUnionFields returned its field values as a plain Erlang list, but the compiler types them obj[], so indexing (fields.[0]) compiled to lists:nth(1, erlang:get(Fields)) and crashed with a function_clause. It now returns an array ref. GetRecordFields never had the problem because it is wrapped at the call site, which a tuple-returning function cannot be.
  • char d passed decimals through unscaled, so Decimal.ToChar returned 65 × 10^28 instead of 65. Both ToChar handlers now unscale first.

Fixed in review

  • Float→decimal was inexact. Scaling by 10^28 in float arithmetic is lossy — 10^28 is not a representable double — so decimal 1.0 came out as 9999999999999999583119736832 rather than 10^28: it compared unequal to the literal 1.0M, and string (decimal 0.1) printed 0.1000000000000000013287555072. This bug already existed in Operators.ToDecimal; the new Decimal.op_Explicit handler was a second copy of it. Both now go through fable_decimal:from_int/1 and from_float/1, the latter converting via the float's shortest round-tripping decimal string. The Decimal.ToSingle/ToDouble tests below cannot catch this — converting back divides the error away again — so Decimal from float is exact compares against the literal and the string instead.
  • PropertyInfo.Name reported the Erlang field name ("string") rather than the F# name ("String"). The property info now carries name (the F# name, which is what PropertyInfo.Name must report) alongside erl_name (the sanitized key the field is stored under), the same split union case infos already use with name/erl_tag. Two Beam tests had #if FABLE_COMPILER branches asserting the lowercase names — they were written around the bug, and now assert the same thing as .NET.
  • A member colliding with its entity's generated reflection function is now an error. sanitizeErlangName is lossy enough that a member TreeReflection on a type Tree mangles onto tree_reflection/0. The form dedup kept the member and silently dropped the reflection function, leaving every typeof<Tree> calling the member instead. It cannot be renamed away (call sites compute the name independently), so it is reported rather than miscompiled.
  • Array.GetEnumerator dereferenced with a raw erlang:get/1, which is wrong for byte[] — byte arrays are atomics, not process-dict refs. It now goes through derefArr.
  • Enum case values are emitted as bignums. Convert.ToInt64 overflowed and took down the compiler on a UInt64-backed enum with a value above Int64.MaxValue. Erlang integers are arbitrary-precision, so the value always fits.
  • Type.IsEnum keys on the presence of enum_cases rather than on it being non-empty, so an enum with no cases is still an enum; and get_enum_name's spec admits the undefined it returns when no case matches.
  • The duplicated record/union construction shared by the inline path and the reflection-function body is factored into makeFieldsEntry/makeCasesEntry.

Known limitations

  • Activator.CreateInstance covers records and primitives. Beam attaches no constructors to type infos (JS uses TypeInfo.construct), so arbitrary classes are not constructible at runtime; the JS Can create generic classes at runtime test is therefore not ported.
  • Decimal→integer conversions are not range-checked, since Erlang has arbitrary-precision integers. JS's Decimal to integer conversions are min/max-checked tests are not ported.
  • A reflection function is emitted for every record and union whether or not anything reflects on it. This matches JS/Python, which rely on bundler tree-shaking; Erlang has no equivalent, so output grows with record count. Accepted.

Testing

Beam suite: 2535 passing, 0 failed (up from 2501). New tests run on .NET first and then on the BEAM. Where JS already had an equivalent test it was ported rather than reinvented — Recursive types work, Reflection functions accept allowAccessToPrivateRepresentation, Reflection works with enums, and the Decimal.To* conversions (which replace a commented-out TODO: System.Decimal.op_Explicit not supported by Fable Beam placeholder in ConversionTests.fs). Decimal.MinValue/MaxValue were restored to Decimal literals can be generated in ArithmeticTests.fs, where Beam had dropped the two lines JS has.

Cross-file reflection gets its own coverage: the per-entity reflection function exists precisely so that a type info can be a remote call, so the fixtures for those tests live in Misc/Util2.fs and are reflected over from ReflectionTests.fs — including a recursive union and a generic record, whose remote call has to get the arity right.

End-to-end, the downstream project that motivated this — previously aborting with Stack overflow (exit 134) — now completes dotnet fable --lang beam and rebar3 compile cleanly.

🤖 Generated with Claude Code

dbrattli and others added 4 commits July 13, 2026 08:03
transformTypeInfo inlined a declared type's fields/cases structurally, so
reflecting over a recursive type (e.g. `Tree = Leaf of int | Branch of Tree * Tree`)
recursed forever and the compiler died with a stack overflow.

Emit a per-entity reflection function for records and unions instead, and have
transformTypeInfo call it by name (`tree_reflection()`, or `module:fn(...)` across
files). The indirection lets a recursive type refer to itself.

Fields and cases are emitted as zero-arity funs, mirroring the lazy `fields`/`cases`
thunks in fable-library-ts, so forcing the type info terminates at runtime too;
fable_reflection forces them at the points that consume them.

Types with no source file (BCL/.dll) have no generated module to call into, so they
keep the inline path, now with a recursion guard.
…imits

Four routing gaps in Beam/Replacements.fs, each already supported on JS/Python:

- FSharpReflectionExtensions: the `allowAccessToPrivateRepresentation` overloads
  resolve to static extensions (`FSharpType.IsUnion.Static`). Route them onto the
  same fable_reflection primitives, ignoring the flag as JS does. fsharpType and
  fsharpValue are extracted so both entities share them.

- Enums: emit enum cases and the underlying type in the type info (enums are
  Number(kind, IsEnum ent) in the Fable AST, so they were being erased to a bare
  numeric type info), and add Type.IsEnum, Enum.GetValues/GetNames/GetName/Parse/
  TryParse/IsDefined/GetUnderlyingType. Also add Array.GetEnumerator, needed to
  iterate the non-generic array Enum.GetValues returns.

- Type.MakeGenericType and Activator.CreateInstance: portable code guards .NET-only
  reflection behind `Compiler.isDotnet`, and while the branch is dead on Fable it
  still has to survive the replacement stage, which runs before DCE. CreateInstance
  covers records and primitives; Beam attaches no constructors to type infos.

- Decimal.MinValue/MaxValue and op_Explicit conversions to and from decimal.

GetUnionFields now returns its field values as an array ref: the compiler types them
`obj[]`, but the result is a tuple, so it cannot wrap them at the call site the way it
does for GetRecordFields. Indexing the fields previously derefed a plain list.
The decimal tests landed in ReflectionTests.fs while implementing the reflection
gaps, but they are conversions, not reflection.

- Decimal.MinValue/MaxValue join "Decimal literals can be generated" in
  ArithmeticTests.fs, restoring the two lines the JS suite has and Beam had dropped.
- The explicit conversions replace the commented-out "TODO: System.Decimal.op_Explicit
  not supported by Fable Beam" placeholder in ConversionTests.fs, ported from the JS
  suite's Decimal.To* tests.

Restoring those uncovered that `char d` was passing the decimal through unscaled
(fixed-scale decimals are integers × 10^28), so Decimal.ToChar returned 65 × 10^28
instead of 65. Both ToChar handlers now unscale first.
…rtyInfo

Follow-up fixes from review of the reflection work.

Converting a float to a decimal scaled by 10^28 in float arithmetic, which is
lossy: 10^28 is not a representable double, so `decimal 1.0` came out as
9999999999999999583119736832 rather than 10^28, compared unequal to `1.0M` and
printed as 0.9999999999999999583119736832. Add fable_decimal:from_int/1 and
from_float/1, the latter going through the float's shortest round-tripping
decimal string, and route both Operators.ToDecimal (where the lossy multiply
already lived) and Decimal.op_Explicit through them. The existing ToSingle /
ToDouble tests cannot catch this since converting back divides the error away,
so compare against the literal and the string instead.

PropertyInfo.Name reported the sanitized Erlang field name ("string") rather
than the F# name ("String"). Split the property info into `name` (the F# name)
and `erl_name` (the map key), as union case infos already do with `name` and
`erl_tag`. Two Beam tests had #if FABLE_COMPILER branches asserting the
lowercase names, i.e. were written around the bug; they now assert the same
thing as .NET.

Also:

- Cover the cross-file path, which is what the per-entity reflection function
  exists for: reflecting over types declared in Misc/Util2.fs emits a remote
  call rather than a local one.
- Report a member colliding with its entity's generated reflection function as
  an error. The dedup kept the member, silently dropping the reflection
  function and leaving every `typeof` for that entity calling the member.
- Deref arrays in Array.GetEnumerator via derefArr, so byte arrays (atomics,
  not process-dict refs) are handled.
- Emit enum case values as bignums; Convert.ToInt64 overflowed and crashed the
  compiler on a UInt64-backed enum above Int64.MaxValue.
- Key Type.IsEnum on the presence of enum_cases, so an enum with no cases is
  still an enum, and admit `undefined` in the get_enum_name spec.
- Factor the duplicated record/union construction into makeFieldsEntry and
  makeCasesEntry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dbrattli dbrattli merged commit b15b280 into main Jul 13, 2026
51 of 53 checks passed
@dbrattli dbrattli deleted the fix/beam-reflection branch July 13, 2026 07:21
dbrattli added a commit that referenced this pull request Jul 14, 2026
…lection calls for erased types

Calling Fable.Core.Testing.Assert.AreEqual/NotEqual directly lowered to a
bare equality expression, so the resulting boolean was computed in
statement position and discarded: the assertion could never fail. The
three-argument overload (custom message) was not supported at all and
failed to compile. Lower them to throwing library calls instead,
mirroring JS and Python.

This does not affect tests/Beam. Fable2Beam intercepts call sites whose
import selector is `Testing_equal`/`assertEqual` and inlines a raising
`case ... erlang:error({assert_equal, Expected, Actual})`, and the
suite's own `equal` in tests/Beam/Util.fs compiles to that selector, so
those assertions have always fired. The bug hits code that calls Assert
directly, such as a downstream test framework.

The new fable_utils:assert_equal/assert_not_equal raise a map carrying
`message` plus the two operands. Fable2Beam already binds a raised map
directly as the exception and reads its `message` key, so `e.Message`
works in `try ... with` while a runner can still pattern-match
`actual`/`expected` off the map, as JS does.

Reflection over an erased ([<Erase>], [<StringEnum>],
[<TypeScriptTaggedUnion>]) or global/imported entity emitted a call to a
`<entity>_reflection/0` function that is never declared, since no
declaration is emitted for such an entity. A record with a field of an
erased type therefore failed to compile when the erased type was in the
same file, and crashed at run time with `undef` when it was in another
one (Erlang does not resolve remote calls at compile time). This is a
regression from b15b280 (#4766), first shipped in 5.8.1, and it blocks
compilation of any project using [<Erase>] types in records. Report the
bare type info for those entities instead. The guard deliberately does
not include isReplacementCandidate: that would short-circuit Result and
Choice into a type info with no cases, making reflection report them as
non-unions.

Both failure modes are covered by regression tests, each verified to fail
without its fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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