fix(beam): make F# reflection work on the Beam target#4766
Merged
Conversation
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
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>
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.
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)
transformTypeInfoinlined 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:Beam now emits a per-entity reflection function for each record/union (
tree_reflection/0,pair_1_reflection/1, …) andtransformTypeInfoemits 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 whatFable2BabelandFable2Pythonalready do.The indirection alone only moves the infinite recursion from compile time to run time, so — mirroring the lazy
fields/casesthunks infable-library-ts— the fields and cases are emitted as zero-arity funs, andfable_reflectionforces 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.2–5. Missing entries in
Beam/Replacements.fsEach of these is already supported on JS/Python:
FSharpReflectionExtensions— theallowAccessToPrivateRepresentationoverloads resolve to static extensions (FSharpType.IsUnion.Static). They now route onto the samefable_reflectionprimitives, ignoring the flag as JS does.fsharpType/fsharpValueare extracted so both entities share them. (The old handlers forwardedargsverbatim, so these overloads' extra argument would have produced an arity error.)Number(kind, IsEnum ent)in the Fable AST, notDeclaredType, so they were being erased to a bare numeric type info. The type info now carriesenum_casesand the underlying type, plusType.IsEnum,Enum.GetValues/GetNames/GetName/Parse/TryParse/IsDefined/GetUnderlyingType. Also addsArray.GetEnumerator, needed to iterate the non-generic arrayEnum.GetValuesreturns.Type.MakeGenericType/Activator.CreateInstance— portable code guards .NET-only reflection behindCompiler.isDotnet, and while the branch is dead on Fable it still has to survive the replacement stage, which runs before DCE.MinValue/MaxValue(both the property and theILFieldGetpath) andop_Explicitconversions to and from decimal.Also fixed along the way
FSharpValue.GetUnionFieldsreturned its field values as a plain Erlang list, but the compiler types themobj[], so indexing (fields.[0]) compiled tolists:nth(1, erlang:get(Fields))and crashed with afunction_clause. It now returns an array ref.GetRecordFieldsnever had the problem because it is wrapped at the call site, which a tuple-returning function cannot be.char dpassed decimals through unscaled, soDecimal.ToCharreturned65 × 10^28instead of65. BothToCharhandlers now unscale first.Fixed in review
decimal 1.0came out as9999999999999999583119736832rather than10^28: it compared unequal to the literal1.0M, andstring (decimal 0.1)printed0.1000000000000000013287555072. This bug already existed inOperators.ToDecimal; the newDecimal.op_Explicithandler was a second copy of it. Both now go throughfable_decimal:from_int/1andfrom_float/1, the latter converting via the float's shortest round-tripping decimal string. TheDecimal.ToSingle/ToDoubletests below cannot catch this — converting back divides the error away again — soDecimal from float is exactcompares against the literal and the string instead.PropertyInfo.Namereported the Erlang field name ("string") rather than the F# name ("String"). The property info now carriesname(the F# name, which is whatPropertyInfo.Namemust report) alongsideerl_name(the sanitized key the field is stored under), the same split union case infos already use withname/erl_tag. Two Beam tests had#if FABLE_COMPILERbranches asserting the lowercase names — they were written around the bug, and now assert the same thing as .NET.sanitizeErlangNameis lossy enough that a memberTreeReflectionon a typeTreemangles ontotree_reflection/0. The form dedup kept the member and silently dropped the reflection function, leaving everytypeof<Tree>calling the member instead. It cannot be renamed away (call sites compute the name independently), so it is reported rather than miscompiled.Array.GetEnumeratordereferenced with a rawerlang:get/1, which is wrong forbyte[]— byte arrays are atomics, not process-dict refs. It now goes throughderefArr.Convert.ToInt64overflowed and took down the compiler on aUInt64-backed enum with a value aboveInt64.MaxValue. Erlang integers are arbitrary-precision, so the value always fits.Type.IsEnumkeys on the presence ofenum_casesrather than on it being non-empty, so an enum with no cases is still an enum; andget_enum_name's spec admits theundefinedit returns when no case matches.makeFieldsEntry/makeCasesEntry.Known limitations
Activator.CreateInstancecovers records and primitives. Beam attaches no constructors to type infos (JS usesTypeInfo.construct), so arbitrary classes are not constructible at runtime; the JSCan create generic classes at runtimetest is therefore not ported.Decimal to integer conversions are min/max-checkedtests are not ported.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 theDecimal.To*conversions (which replace a commented-outTODO: System.Decimal.op_Explicit not supported by Fable Beamplaceholder inConversionTests.fs).Decimal.MinValue/MaxValuewere restored toDecimal literals can be generatedinArithmeticTests.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.fsand are reflected over fromReflectionTests.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 completesdotnet fable --lang beamandrebar3 compilecleanly.🤖 Generated with Claude Code