Skip to content

Implement Typed IR - #504

Open
Derppening wants to merge 259 commits into
hkust-taco:hkmc2from
Derppening:enhance/typed-ir
Open

Implement Typed IR#504
Derppening wants to merge 259 commits into
hkust-taco:hkmc2from
Derppening:enhance/typed-ir

Conversation

@Derppening

@Derppening Derppening commented May 27, 2026

Copy link
Copy Markdown
Contributor

The following summary is generated by Claude and reviewed by me.

Summary

This PR gives the Block IR a notion of type. Every node and symbol that can carry a value now has an
erased type - its type with generics stripped - and a new Cast node makes every narrowing explicit.

The motivation is the Wasm backend, which previously represented everything as anyref and re-established
types with a runtime cast at each use site. It can now declare struct fields and function signatures at their
real types, keep Int32 unboxed as a native i32, and cast only where a narrowing genuinely occurs. JS
behaviour is unchanged: it erases unchecked casts.

The typed IR

Types come from annotations only - parameter and return signatures, val/class-parameter/field
annotations, and a class's own identity. The compiler never infers a type from a function body, so an
unannotated function has no known type. The one exception is the synthesized module entry point, which has
no signature to annotate; its result type is derived from its body.

Much of the IR is still untyped, and nothing depends on complete coverage. An unannotated slot has no
type, and reading it folds to Unknown (see below). Typing more of the IR is a strict improvement: it only
ever replaces Unknown with something narrower.

Beyond annotations, a type is derived from a node's own contents wherever that is free: literals, this,
class and module references, instantiations, tuple literals, exactly- and under-applied calls to annotated
functions, references to annotated members, and casts. Anything needing flow analysis stays unknown.

The type hierarchy

Smaller than the surface type system: no type arguments, no structural types, no bottom type - and no top
type either. The erased types form a forest, not a lattice.

Unknown                                      Int32   Int64   Float32   Float64      FuncRef
├── Object
│   └── Array, Function,                     (unboxed primitives: each is a root    (a signature, not a
│       <user classes and modules>            of its own, with no supertype)         value type)
├── Bool
├── Str
└── Num
    └── Int
        └── Int31
  • There is no common top type. Anything is the surface top and has no erased counterpart of its own -
    it erases to Unknown. A type subsuming both a uniformly represented value and an unboxed i32 cannot be
    compiled to a target like Wasm without implicit boxing, and boxing is meant to be an explicit operation in
    both the surface language and the IR.
  • Unknown is a value with a uniform representation - anyref on Wasm, any value on JS - whose identity
    is not known statically, though it can still be tested at runtime: if x is Int lowers to an ordinary
    match. It is not surface syntax and carries no type symbol. An unresolvable alias and a written
    Anything are Unknown; a slot with no annotation has no type at all, and folds to Unknown when read.
  • Object is the part of that space that excludes the host primitives: user classes and modules, arrays
    and tuples, and functions. Both backends already agreed on the exclusion (1 instanceof Object is false
    on JS; an i31ref is not a subtype of the $Object struct on Wasm), so Bool, Str, Num and Int now
    sit beside Object rather than under it. The exclusion walks the parent chain rather than testing a fixed
    symbol set, so it stays descendant-closed: a user class extending Int is excluded along with Int.
  • The four primitives are roots, with no supertype - not even Unknown - because they have no uniform
    representation. They are the only types eligible for unboxed lowering; so far only Int32 is. A primitive
    is compatible only with the same primitive, in either direction: passing one into a slot of any other
    type - an unannotated one included - is a compile error rather than a silent widening the backend
    rejects later, without a source location. Int32 and Int are therefore unrelated despite both being
    integers; creating an Int32, and converting between it and the boxed integers, go through the Wasm
    intrinsics.
  • The numeric types form a chain, Int31 <: Int <: Num, so an integer literal flows into a Num slot
    as a widening - no cast, no error.
  • FuncRef is a signature, not a value type, which is why it sits outside the hierarchy rather than
    under Object. It carries an annotated function's parameter lists and result, so curried and partially
    applied functions are representable and a call can be typed by how saturated it is. No value has it:
    coercing into a slot declared with one targets the Function reference type instead, which is under
    Object.

Least upper bounds are partial. Object and Int have Unknown as their bound; Int32 and Str have
none, since no single representation admits both. Such a pair yields an Incompatible type, which absorbs
every type it is later joined with - keeping the first failing pair, the conflict worth reporting - and is
diagnosed where it is first used to coerce a value. Making the operation partial is what lets the hierarchy
drop its top type without silently admitting uncompilable programs.

Canonicalization happens lazily, once, on first use: aliases resolve to their target, unions collapse to
their least upper bound, and primitive symbols are reclassified as primitives. The laziness lets types be
recorded while the prelude - which defines those very types - is still being elaborated.

Two representation gaps remain on Wasm, both predating this change: tuple arrays are not part of the
$Object family, and function values are only backed by a closure struct when the optional first-class
function pass runs. So Object still lowers to anyref there rather than to the $Object struct type.

Casts

A Cast represents coercing a value into a slot of a different type. One decision procedure governs it:

Relationship Result
The value's type is already a subtype of the slot's No cast
The slot's type is a subtype of the value's A narrowing Cast
The relationship cannot be decided A conservative narrowing Cast
The two are provably unrelated Compile-time error; the value passes through uncast

The last row covers two distinct failures, each with its own message. Types related by nothing - including a
primitive and anything that is not the same primitive - give "Cannot use a value of type 'X' at an unrelated
type 'Y'"
. An Incompatible type is not unrelated to the slot but unrepresentable in itself, so its message
names both members: "Types 'X' and 'Y' have no common representation".

Coercions are introduced wherever the program declares the destination's type: returning from a function with
a declared return type; initializing or assigning an annotated val, local, or field; passing an argument to
an annotated parameter; and returning from a merged tail-call dispatcher, which declares the LUB of the
return types it merges.

Invariants

  • Casts strictly narrow. Neither an upcast nor an identity cast is ever represented: a value already of
    the slot's type, or of a subtype of it, passes through unchanged.
  • A cast's operand is never itself a cast. Nested casts collapse to a single cast to the outer target -
    sound precisely because casts strictly narrow, so passing the outer test implies passing the inner one.
    What can be lost is a failure message, never type safety.
  • An undecidable relationship never produces an error. When the relationship cannot be decided (unlinked
    import, cyclic parent chain) the value's type is treated as Unknown for that decision only and a
    conservative checked cast is emitted; an error is raised only on proven unrelatedness, since an unneeded
    cast is harmless and a missing one is unsound. The substitution is never written back, which would pollute
    LUBs, printing and identity.
  • A checked cast is impure - it can throw. An unchecked cast is pure.
  • The check flag is decided once, where the coercion is introduced, and copied by every pass that
    rebuilds the node, so the configuration need not be threaded through the IR transformers.

A cast between a literal and its use would block constant folding, so the simplifier folds literals through
casts - safe because backends type such positions from the slot, not from the value.

Checked casts (:checkCasts)

Casts are unchecked by default: static assertions that JS erases and Wasm lowers to a trapping ref.cast.
The new checkCasts flag expands them into a runtime type test that throws
Cannot narrow a value to type 'X' on failure. The expansion runs last in the pipeline, so no later pass
can discard a check, and it cannot destroy the shape tail-call optimization recognizes.

Every cast the IR can build is testable. Object is tested like any other class - globalThis.Object agrees
with the hierarchy case for case now that the primitively represented types are outside it - and primitives
are not cast targets at all, since coercing into one is rejected before a Cast is built. The remaining "no
test can be expressed" arm exists for exhaustivity and carries a softTODO: reaching it leaves the cast as
it would have been without the flag, which is the status quo rather than a miscompilation.

The IR printer renders a checked cast as! and an unchecked one as!! (asserted with, and without, a
runtime check).

Wasm backend

Consuming the IR's types rather than falling back to anyref:

  • struct fields, parameters, results, locals and globals are declared at their real types;
  • constructors and initializers return a concrete reference type;
  • Int32 is lowered unboxed as a native i32;
  • the module entry point's result type is derived from its body;
  • new diagnostics reject a function whose body type disagrees with its declared result, and an override that
    changes a primitive parameter or result type - neither is expressible in Wasm's type system.

The payoff is visible in wasm/Casts.mls: the backend's own "Cannot cast a Wasm value of type i32 to
(ref null any) in a different type hierarchy"
, which carries no source location, now appears directly
below two located IR diagnostics saying the same thing. The redesign moves a rejection the backend already
performed up into the IR, where it can point at the code.

Testing

codegen/ErasedTypes.mls and codegen/CheckedCasts.mls are the dedicated test files, with wasm/Casts.mls
covering both on Wasm. The new :siret directive makes the IR printer show erased types alongside
:sir/:soir. Cast tests use :noInline where the inliner would otherwise see through the opacity function
that makes the coercion necessary.

Non-goals / known limitations

  • Records and lambda values are left untyped. Tuples are typed as Array; records and lambdas carry no
    type of their own.
  • A written arrow type erases to nothing. Int -> Int is ordinary surface syntax and is accepted on a
    val, parameter or declared result, but the slot ends up as untyped as an unannotated one: any reference
    value flows into it uncast, and only a primitive is rejected. Erasure yields a value type and an arrow
    is not one; erasing it to the Function reference type would fit, and is the intended direction.
    Annotating the slot Function instead does narrow, and does raise the unrelated-type error.
  • A value of unknown type is treated as narrowable at every coercion, so flowing it into an annotated
    slot always emits a cast. Deliberate - narrowing from Unknown is a genuine downcast - but it makes
    casts more frequent than the program strictly requires.
  • TailRecOpt forgets erased types and merges parameter lists without checking that they are compatible,
    which can silently drop a primitive-vs-reference disagreement between mutually tail-recursive functions.
    Pinned by a :fixme in wasm/Casts.mls.
  • Only Int32 has a Wasm lowering. Int64, Float32 and Float64 are in the hierarchy and obey the
    primitive rules, but the backend registers no value type for them, so they never become i64, f32 or
    f64 - a slot declared at one falls back to anyref. No intrinsic constructs such a value either, so
    nothing can legally reach that slot.
  • No closures on Wasm, and a checked cast to a virtual class target is unsupported there (pre-existing).

Incidental fixes and tooling

Fixes to pre-existing behaviour:

  • Fixed tail-recursion optimization matching against the wrong target, silently disabling it for let-bound
    tail calls.
  • Fixed forward references between top-level functions failing on Wasm, by predeclaring them.
  • Fixed builtin symbols not resolving inside nested modules, both when elaborating them and when
    reconstructing their path during lowering.
  • Fixed data-flow analysis facts using structural equality, which was exponential over the shared IR graph.
  • Fixed class constructor function types not being propagated to Wasm exports.

New diagnostics unrelated to typing:

  • Using a wasm.* intrinsic while targeting JS now reports "WebAssembly intrinsics are not available when
    targeting JS"
    at the use site, instead of failing at runtime with ReferenceError: wasm is not defined.

The Wasm test harness also drops the hkust-taco/binaryen.js fork dependency: the published binaryen npm
package now accepts a feature set when parsing WAT.

`Call`, `Lambda`, `Select`, and `DynSelect` is left for a future commit.

@LPTK LPTK left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some possible next immediate steps:

  • Update printer to show the erased types at variable and member declaration/definition sites.
  • Update Lowering so it generates erased types from parameter type annotations, to be used to annotate the corresponding VarSymbol.
  • Make sure we are never overriding an existing erased type in a given symbol by using softAssert, as a sanity check.

A subtlety we should get right: the erasure of annotated class parameter types should successfully propagate to their defining fields. Param has a ``fldSym` which can be used for this.

Comment thread hkmc2/shared/src/main/scala/hkmc2/codegen/js/JSBuilder.scala Outdated
new Rewriter(instId).applyBlock(ogBody),
mkReturnCall(restFunSym, restFunArgs))
val refreshedFvSymbols = dtorBranchFnFvs(branchId._1).map(s => s -> new VarSymbol(Tree.Ident(s"fv_${s.nme}")))
val refreshedFvSymbols = dtorBranchFnFvs(branchId._1).map(s => s -> new VarSymbol(Tree.Ident(s"fv_${s.nme}"), erasedType = N))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems many cases like this one should carry over the previous erasedType somehow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still outstanding, but I am unsure how to recover the erased type here so I'll leave this for now.

Comment thread hkmc2/shared/src/main/scala/hkmc2/codegen/Block.scala Outdated
@Derppening

Derppening commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

The current task list (work items created by me, organized by AI):

  • Phase A — erasedType on Result (keystone infra) — ✅ committed

    • Enum redesign — PrimitiveType.Array + totalized sym; three-case ErasedType; ErasedType.sym; erasedType_!; Normalization → ObjectRef
    • Infra — Result extends HasErasedType; lazy val erasedType; abstract def on trait; Value's override val removed; this match over Tuple/Record/Instantiate/Value
    • Materialization sweep (Cat 1 + 2 + 3) — all applied, compiles clean, committed
    • Core sites (subTerm, ReflectionInstrumenter.assign, ValDefn.mk); join-point traps left N
  • Phase B — printer baseline (was C1) — captures post-A erasedType so later tightening shows as a diff

    • showErasedType toggle in Printer.scala (mirror showPurity, default OFF); render at variable + member declaration/definition sites
    • Commit one curated baseline test file (tuple/instantiate/lit/record/call/select/val-def, ≥1 case through Lifter) with post-A types as-is
  • Phase C — Annotation-driven erasedType + invariant guard (was Phase B; lands right after the B baseline)

    • Param-annotation → VarSymbol (Lowering erases param type annotation onto the VarSymbol)
    • Class-param erasure → defining field via Param.fldSym
    • softAssert no-clobber invariant (never override an existing symbol erasedType)
  • Phase D — WatBuilder consumes ErasedType (was C2; correctness harness)

    • Drive anyref cast targets from operand erasedType (additive + N-graceful); WASM goldens shift here
    • Optional explicit asserts when a known erasedType contradicts the required use-site type
  • Phase E — FuncRef + AnyFuncRef (was Phase D)

    • Add AnyFuncRef coarse constant, then FuncRef(params, result)
    • Fill Lambda → FuncRef
  • Phase F — refine residual inference (was Phase E)

    • Call return types (easy win: builtin-op result-type table, survey §6)
    • Select/DynSelect field/member types
    • rest params; Lifter capture symbols; function results
    • revisit resSym/l sites left N in Phase A

@LPTK

LPTK commented Jun 1, 2026

Copy link
Copy Markdown
Contributor
  • join-point traps left N

What does that mean?

  • C1 — showErasedType toggle in Printer.scala

This should be moved to phase B. In fact, it's the first thing yoiu should do, just so you can actually see what you're doing!

@Derppening

Copy link
Copy Markdown
Contributor Author
  • join-point traps left N

What does that mean?

erasedType = N, will be left for Phase D.

What does that mean?

  • C1 — showErasedType toggle in Printer.scala

This should be moved to phase B. In fact, it's the first thijng yoiu should do, just so you can actually see what you're doing!

Good point, I have updated to task list.

Comment thread hkmc2/shared/src/main/scala/hkmc2/codegen/Block.scala Outdated
Comment thread hkmc2/shared/src/main/scala/hkmc2/codegen/Block.scala Outdated
Comment thread hkmc2/shared/src/main/scala/hkmc2/semantics/Symbol.scala Outdated
Comment thread hkmc2/shared/src/test/mlscript/codegen/ErasedType.mls Outdated
Comment thread hkmc2/shared/src/test/mlscript/codegen/ErasedType.mls Outdated
Comment thread hkmc2/shared/src/main/scala/hkmc2/codegen/Printer.scala Outdated
@Derppening

Copy link
Copy Markdown
Contributor Author

I will address the remaining comments, add issues for follow-up, and re-read all the slopdoc to make sure that they aren't sloppy slop slop in a bit.

@Derppening

Derppening commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author
  • Ensure that Cast nodes are the only ground truth for hierarchical casts in Wasm - Wasm's own cast helper should only cast between nullable and non-null types Deferred for a future PR
  • Address remaining comments (including ones attached to commits rather than this PR thread)
  • Add issues for follow-up
    • Support+intrinsics for Int64,Float{32,64}
    • CheckedCastExpansion
    • Erased array types storing its element type
    • Accept a type annotation for let bindings
    • Emit more Cast nodes in the IR rather than in the backend
  • Re-read all slopdoc

@Derppening

Derppening commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

(In response to comments for c1a9cd7)

What is this asMod doing here?? We already know this reference points to the type alias (which is not compilable; should be a softAssert or crash)

I tried to replace the TypeAliasSymbol arm with a lastWords, but it replaces a compilation error with an InternalError, and since Result.erasedType is a lazy val and nowhere does a Raise flow into Result, I can't use a softAssert there.

FYI the two test failures are:

  • :e
    assertModule(TypMod).whoami
    //│ FAILURE: Unexpected exception
    //│ /!!!\ Uncaught error: java.lang.Exception: Internal Error: Type alias member reference `type:TypMod`
    

    (which used to be a compilation error: Cannot use a value of type 'module TypMod' at an unrelated type 'module ClsMod')

  • // FIXME: should not resolve to module's `.whoami`, hence should be an error
    :breakme
    assertNonModule(TypMod).whoami
    //│ FAILURE: Unexpected exception
    //│ /!!!\ Uncaught error: java.lang.Exception: Internal Error: Type alias member reference `type:TypMod`
    

    (which used to just output = "module")

@Derppening

Derppening commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

(In response to comments for 98d83af)

Make sure to preserve these tests in the branch for the future PR.

(Similarly fr the other deletions in other files.)

All preserved - The branch is on 6b13fa7 + a commit containing the remainder of :noCheckCast changes.

There is no reason to remove this logic at this point. The checkCasts option is already in Config.

Sorry, once again GitHub swallowed your comments on the commit and I'm not sure where you put it. Do you mean the :noCheckCast directives in MLsDiffMaker.scala?

Comment thread hkmc2/shared/src/main/scala/hkmc2/codegen/Lifter.scala Outdated

private val captureSym = TermSymbol(syntax.ImmutVal, S(obj.cls.isym), Tree.Ident(obj.nme + "$cap"))
override lazy val capturePath: Path = Select(Value.This(obj.cls.isym), captureSym.id)(S(captureSym))(false)
private val captureSym = TermSymbol(syntax.ImmutVal, S(obj.cls.isym), Tree.Ident(obj.nme + "$cap"), erasedType = N)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude seems to flag this as the wrong location for the change + a latent bug:

These aren't just untyped. The private val captureSym can't override the trait's, so it's a
second symbol: appendCaptureField/initCaptureField bind to ClsLikeRewrittenScope's
captureSym and declare/assign the field, while the subclass capturePath reads through the
private copy. They agree only because both spell Tree.Ident(obj.nme + "$cap") — by name, not
identity. The compiler says so if you disturb it: switching to private lazy val gives
private lazy value captureSym cannot override lazy value captureSym in trait ClsLikeRewrittenScope.

Typing them would annotate the reader and leave the declarer ?, so I typed the trait's symbol
instead and left these untouched. The actual fix is to delete all three redeclarations — each
subclass mixes the trait in with exactly the sym it uses (obj.cls.isym / obj.clsBody.isym),
so they're duplicates of 893/894. Pre-existing on hkmc2, filing separately.

Worth noting nothing in the suite exercises a class-like scope's own capture — every $cap in
the goldens is an enclosing-scope reference — which is why this never surfaced.


private val captureSym = TermSymbol(syntax.ImmutVal, S(obj.clsBody.isym), Tree.Ident(obj.nme + "$cap"))
override lazy val capturePath: Path = Select(Value.This(obj.clsBody.isym), captureSym.id)(S(captureSym))(false)
private val captureSym = TermSymbol(syntax.ImmutVal, S(obj.clsBody.isym), Tree.Ident(obj.nme + "$cap"), erasedType = N)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment from Claude as #504 (comment).

Comment thread hkmc2/shared/src/main/scala/hkmc2/codegen/Lifter.scala Outdated
Comment thread hkmc2/shared/src/main/scala/hkmc2/codegen/Lifter.scala Outdated
(
VarSymbol(Tree.Ident(nme)),
TermSymbol(syntax.LetBind, S(obj.cls.isym), Tree.Ident(nme))
VarSymbol(Tree.Ident(nme), erasedType = N),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude seems to think there would be a dependency ordering issue.

These are the receiving end of an enclosing scope's capture, so the type
is data.getNode(i)'s capture class via ctx.rewrittenScopes, not this.captureClass. And
capSymsMap_ is an eager private val, so typing it reaches into another scope's lazy
captureInfo during construction — an init-order dependency I'd rather not introduce as a
drive-by. The twin in LiftedFunc (1038) is the one actually visible in the goldens:
fun reset⁰(resetCounter$cap: ?) against the now-typed
let resetCounter$cap: Capture$resetCounter¹ — same capture object, typed at the producing end
and untyped at the consuming end.

FYI, I tried to make capSymsMap_ (and its dependents) lazy in a5416c3 and it seems to work - but it'd be best if you could take a look as well.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, it should work but it seems a bit error-prone. I will probably make a follow-up PR at some point to clean up some of the things in the lifter (not just because of this PR, some things could be cleaner)

(
VarSymbol(Tree.Ident(i.nme + "$")),
TermSymbol(syntax.LetBind, S(obj.cls.isym), Tree.Ident(i.nme + "$"))
VarSymbol(Tree.Ident(i.nme + "$"), erasedType = N),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 5eb3b28 - Also give it a look.


val flattenedSym = BlockMemberSymbol(obj.cls.sym.nme + "$", Nil, true)
val flattenedDSym = TermSymbol.fromFunBms(flattenedSym, N)
val flattenedDSym = TermSymbol.fromFunBms(flattenedSym, N, erasedType = N)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about the scope of this PR, but it should be possible to assign a type here (I can fix these issues in a later PR if needed)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in c26937d - Give it a look and see if that's correct.

@LPTK

LPTK commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Do you mean the :noCheckCast directives in MLsDiffMaker.scala?

Yes. Why remove the config command?

@Derppening

Derppening commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

(In response to comments for 4d5e4f8)

I don't think this is actually ambiguous. A partially applied function should already be given erasure Function, so what's the problem if fun (+): (Int, Int) -> Int is always interpreted as taking two Ints in its first parameter list?

The thing about @untyped is just bonkers. Sloppy justification that misses the big picture, as usual. Why not just give @untyped node Unknwon as the erasure?

I told Claude to redesign the whole thing (it was previously written by DeepSeek and it ran into many issues) - Implemented in d4e6778.

Edit: I forgot to review the difftest descriptions - will do so tomorrow. Hope there's not a lot of slop there 😢

@Derppening

Copy link
Copy Markdown
Contributor Author

Do you mean the :noCheckCast directives in MLsDiffMaker.scala?

Yes. Why remove the config command?

Reinstated in 5f7fdbf.

@LPTK

LPTK commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

[x] Ensure that Cast nodes are the only ground truth for hierarchical casts in Wasm - Wasm's own cast helper should only cast between nullable and non-null types Deferred for a future PR

Don't forget to open an issue, then 😉

@LPTK

LPTK commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

(In response to comments for c1a9cd7)

Addressed.

@Derppening

Copy link
Copy Markdown
Contributor Author

Comments for 5f7fdbf:

Why does the slopdoc refer to a "mirror"? I guess it just means the reflection instrumenter.

It refers to the other variant of the test block, which has the same body but without the :noCheckCast flag.

Comments for d4e6778:

Addressed in 95ee9cb and 7b901ab - the first commit applies your suggestion to just type paramless funs as the erased type of its result, and the second commit implements @untyped as a simple Cast-to-Unknown on any term. I went with that instead of a Runtime.untyped since it is more self-contained and achieves largely the same as the runtime function.

@Derppening

Copy link
Copy Markdown
Contributor Author

[x] Ensure that Cast nodes are the only ground truth for hierarchical casts in Wasm - Wasm's own cast helper should only cast between nullable and non-null types Deferred for a future PR

Don't forget to open an issue, then 😉

Opened #547, #548, #549, #550, and #551 for each item listed in #504 (comment).

@LPTK

LPTK commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

It refers to the other variant of the test block, which has the same body but without the :noCheckCast flag.

Is there still such a "mirror" here even though the flag currently has no effect?

// * A `fun` with no parameter lists is a getter, so it has no `FuncRef` and `Lowering.ref` auto-invokes it
// * with one empty argument list - the getter's own type is the result type of the call.
// * Any further application is handled as an over-applied call.
case other if (ts.k is syntax.Fun) && argss.sizeIs == 1 && argss.head.isEmpty => other

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic seems a bit messed up.

I think what's happening is that two cases are being conflated:

  • Class/object/module fun member are compiled to getters and genuinely do not have any parameter lists.
  • Local fun bindings always need at least one parameter list, so a parameterless local fun f is actually compiled to fun f(). The erased type should match what something is compiled to – so in this local fun case, the erased type should contain the implicitly added parameter list.

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.

5 participants