Skip to content

fix(beam): make Assert.AreEqual/NotEqual raise, and stop emitting reflection calls for erased types#4775

Merged
dbrattli merged 1 commit into
mainfrom
fix/beam-assert-and-erased-reflection
Jul 14, 2026
Merged

fix(beam): make Assert.AreEqual/NotEqual raise, and stop emitting reflection calls for erased types#4775
dbrattli merged 1 commit into
mainfrom
fix/beam-assert-and-erased-reflection

Conversation

@dbrattli

@dbrattli dbrattli commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Two independent Beam-target bugs, both found while upgrading Fable.Beam from 5.6.0 to 5.8.1.

1. Assert.AreEqual / Assert.NotEqual never raised when called directly

Beam/Replacements.fs lowered them to a bare equality expression. The resulting boolean sits in statement position, so it is computed and discarded — the assertion could never fail. The three-argument overload (custom message) was not supported at all: Assert.AreEqual(a, b, "msg") was a hard compile error (Fable.Core.Testing.Assert.AreEqual is not supported, try updating fable tool).

They now lower to throwing library calls, mirroring JS (Replacements.fs) and Python.

Scope: this does not mean the Beam test suite was unasserted. Fable2Beam.fs:2261 intercepts call sites whose import selector is Testing_equal/assertEqual and inlines a raising assertion, with no runtime dependency:

test_infix_add_can_be_generated() ->
    case fable_comparison:equals(999, 6) of
        true  -> ok;
        false -> erlang:error({assert_equal, 6, 999})
    end.

tests/Beam/Util.fs's equal compiles to that selector, so the suite's assertions have always fired (verified by corrupting an expected value under the pre-fix compiler and watching it fail). The bug bites code that calls Fable.Core.Testing.Assert directly, which is what a downstream test framework does — that's how it was found.

The new fable_utils:assert_equal/2,3 and assert_not_equal/2,3 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 reads cleanly in try ... with, while a runner can still pattern-match actual/expected off the map. This mirrors JS, which throws an Exception with the message and attaches actual/expected as properties.

Note this leaves Beam with two assertion shapes: the inlined {assert_equal, Expected, Actual} tuple at Testing_equal call sites, and the new #{message, actual, expected} map from the library. Consolidating them is worth a follow-up, but is out of scope here.

2. Erased types emitted dangling <entity>_reflection() calls (regression, blocks compilation)

Regression from b15b280 ("fix(beam): make F# reflection work on the Beam target", #4766), first shipped in 5.8.1.

That change made a reference to a record/union emit a call to the entity's generated <entity>_reflection/0. But an erased ([<Erase>], [<StringEnum>], [<TypeScriptTaggedUnion>]) or global/imported entity never gets a declaration emitted, so there is no reflection function to call:

[<Erase>]
type Shutdown = Shutdown of obj        // erased: no Erlang declaration emitted

type ChildSpec = { Shutdown: Shutdown } // its reflection fn calls shutdown_reflection()

Two failure modes, the second nastier:

  • Local (erased type in the same file) → compile error: function shutdown_reflection/0 undefined.
  • Remote (erased type in another module) → Erlang does not resolve remote calls at compile time, so it compiles fine and crashes at run time with undef.

This blocks compilation of any project using [<Erase>] types in records — an idiomatic pattern for zero-cost opaque Erlang types.

Fixed by reporting the bare type info for entities that produce no declaration, which is what Python already does. The guard is deliberately kept to exactly the two "no declaration is emitted" predicates: including isReplacementCandidate (as Python's does) short-circuits Result and Choice into a type info with no cases entry, so reflection reports them as non-unions. They have no SourcePath and so never reached the call branch anyway.

Tests

tests/Beam/AssertTests.fs (new) asserts that a mismatching Assert.AreEqual/NotEqual throws and that e.Message is the expected string. It calls Assert directly rather than through the suite's equal, which is the path that was broken; anything that must fail the test fails it with failwith.

ReflectionTests.fs / Misc/Util2.fs cover reflection over a record with an erased field in both the local and the cross-module position — only the local one is caught at compile time, so the remote variant needs its own run-time coverage. ReflectionTests.fs already covers Result/Choice, which pins the isReplacementCandidate trap above.

Each new test was verified to fail without its corresponding fix. Full suite: 2586 passed, 0 failed.

🤖 Generated with Claude Code

…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>
@dbrattli dbrattli force-pushed the fix/beam-assert-and-erased-reflection branch from 8b5010f to b5843ab Compare July 14, 2026 12:18
@dbrattli dbrattli merged commit d9711d4 into main Jul 14, 2026
31 checks passed
@dbrattli dbrattli deleted the fix/beam-assert-and-erased-reflection branch July 14, 2026 12:29
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