Skip to content

Implement Typed IR - #504

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

Implement Typed IR#504
Derppening wants to merge 190 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.

Note: This PR depends on LPTK#27.

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 works a type out 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.

An unknown type is distinct from the top type. Much of the IR is still untyped, and nothing depends on
complete coverage: an unknown type is treated as top at the point a decision must be made, and nowhere else.
Typing more of the IR is a strict improvement.

Where no type is declared, one is derived from the node's own contents wherever that is free: literals,
this, class and module references, instantiations, 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.

                    Anything                  (true top: admits everything)
                   /        \
              Object         Int32 Int64 Float32 Float64      (unboxed primitives)
             /  |  \  \
    Bool  Num  Str  Array  <user classes, modules>            (reference types)
           |
          Int
           |
         Int31
  • Anything is the true top, above primitives and functions alike; Object is the base of all
    reference types.
  • The four primitives are <: Anything and nothing else — in particular not <: Object. They are
    the only types eligible for unboxed lowering; so far only Int32 is actually lowered that way.
  • 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.
  • Function types carry their parameter lists, so curried and partially applied functions are
    representable. A function becomes a Function reference when used as a value.

Int32 and Int sit on opposite sides of the boxed/unboxed divide and are therefore unrelated: converting
between them is a compile error, not a cast. Creating an Int32, and converting between it and the boxed
integers, both go through the Wasm intrinsics.

Canonicalization happens lazily, once, on first use: aliases resolve to their target (an unresolvable one
becomes top), 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. One intentional imprecision: an alias to a union resolves to top rather than to its
members' LUB, weakening such signatures.

Object as a type and Object as a runtime test deliberately disagree. As a type it is the base of all
reference types, so fun f(): Object = 1 type-checks; as a runtime test it compiles to a host-level check
that primitively represented values fail, so 1 is Object is false. This matches existing behaviour on
both backends — the pattern-matching path is untouched — and is pinned by tests. The distinction is what
makes LUB useful: collapsing the two degrades the LUB of unrelated classes from Object to Anything.

Casts

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

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 two are provably unrelated Compile-time error; the value passes through uncast

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 unknown type is treated as top only at the cast decision, never normalized to top globally, which
    would pollute LUBs, printing and identity.
  • Ignorance never produces an error. When a relationship cannot be decided (unlinked import, cyclic
    parent chain) a conservative checked cast is emitted; a compile error is raised only on proven
    unrelatedness. An unneeded cast is harmless, a missing one is unsound.
  • 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.

Two targets are deliberately untested: Object, which admits every reference the IR can produce, and the
unboxed primitives, which JS has no representation to test and Wasm already rejects while lowering. Both are
pinned by tests.

The IR printer renders a checked cast as and an unchecked one as! (asserted, not verified). Neither is
surface syntax.

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.

Testing

codegen/ErasedType.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

  • Tuples, records and lambda values are left untyped. Typing tuples as Array was measured corpus-wide:
    zero improvements, and it breaks two compilation tests because the standard library treats the two as
    disjoint. The blocker is a library decision, not a codegen one.
  • A function type used as a value's type erases to nothing — a val, parameter or declared result
    annotated Int -> Int takes no coercion and cannot raise the unrelated-type error. Erasure yields a
    value type and a function type is not one; erasing to the Function reference type would fit, and is the
    intended direction.
  • A value of unknown type is treated as top at every coercion, so flowing it into an annotated slot
    always emits a cast. Deliberate — narrowing from an unknown type is a genuine downcast — but it makes
    casts more frequent than the program strictly requires.
  • 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.
  • 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.

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, the only thing the fork's extra entry point provided.

@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.

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
Comment thread hkmc2/shared/src/main/scala/hkmc2/semantics/Symbol.scala Outdated
Comment thread hkmc2/shared/src/test/mlscript/codegen/ErasedType.mls Outdated
@Derppening
Derppening marked this pull request as ready for review August 14, 2026 14:41
@Derppening

Derppening commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

The changes should be all ready. Note that:

@LPTK

LPTK commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

One intentional imprecision: an alias to a union resolves to top rather than to its
members' LUB, weakening such signatures.

What's the rationale for this?

@LPTK

LPTK commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Object as a type and Object as a runtime test deliberately disagree. As a type it is the base of all
reference types, so fun f(): Object = 1 type-checks; as a runtime test it compiles to a host-level check
that primitively represented values fail, so 1 is Object is false. This matches existing behaviour on
both backends — the pattern-matching path is untouched — and is pinned by tests.

This is presented as though it was a feature, but it makes no sense and obviously leads to bad user experience/surprises, which we baturally want to avoid.

Would there be any downside to not placing Num and Bool under Object, instead placing them directly under Anything?

The distinction is what makes LUB useful: collapsing the two degrades the LUB of unrelated classes from Object to Anything.

I suspect this is LLM gibberish devoid of substance.

@LPTK

LPTK commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

The IR printer renders a checked cast as and an unchecked one as! (asserted, not verified). Neither is
surface syntax.

It should be as! and as!!, respectively. https://github.com/hkust-taco/mlscript-design-docs/blob/main/wiki/Casts.md

@Derppening

Derppening commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

One intentional imprecision: an alias to a union resolves to top rather than to its
members' LUB, weakening such signatures.

What's the rationale for this?

Good catch, that was a gap that was missing its implementation while LUB/alias resolution was first implemented. Fixed in 0827be1, where an alias to a union resolves to the LUB of the union members as expected.

Object as a type and Object as a runtime test deliberately disagree. As a type it is the base of all
reference types, so fun f(): Object = 1 type-checks; as a runtime test it compiles to a host-level check
that primitively represented values fail, so 1 is Object is false. This matches existing behaviour on
both backends — the pattern-matching path is untouched — and is pinned by tests.

This is presented as though it was a feature, but it makes no sense and obviously leads to bad user experience/surprises, which we baturally want to avoid.

Would there be any downside to not placing Num and Bool under Object, instead placing them directly under Anything?

Making Bool <: Anything has no downsides.

Changing Num <: Anything would change the meaning of Object - Right now, Object is the top type of all reference types, and as such Int <: Object. If we instead make Num <: Anything (and therefore Int <: Anything), it would make more sense if we instead define Object as the top type of all reference types that are non-JS primitives instead (i.e. Bool, Int, Num, Str), which better matches both JS (1 instanceof Object === false) and Wasm (i31ref </: $Object) semantics. I think this is an improvement over what we have right now.

Note that Str has similar properties as Num ("foo" instanceof Object === false in JS, and does not extend $Object in Wasm), and probably should have the same treatment as Num.

Let me know if this direction is a better approach than the Object mess we have right now - I'll make the change if so.

The distinction is what makes LUB useful: collapsing the two degrades the LUB of unrelated classes from Object to Anything.

I suspect this is LLM gibberish devoid of substance.

Yeah... That's a whole lot of nothing.

The IR printer renders a checked cast as and an unchecked one as! (asserted, not verified). Neither is
surface syntax.

It should be as! and as!!, respectively. https://github.com/hkust-taco/mlscript-design-docs/blob/main/wiki/Casts.md

Done in bc2c57f, changed checked and unchecked casts to print as as! and as!! instead.

@LPTK

LPTK commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Changing Num <: Anything would change the meaning of Object - Right now, Object is the top type of all reference types, and as such Int <: Object.

I don't understand your poitn (is it really your point or some other LLM nonsense?). Int is not meant to be a "reference type" from the user's perspective (how it gets compiled to WASM is irrelevant, here).

If we instead make Num <: Anything (and therefore Int <: Anything), it would make more sense if we instead define Object as the top type of all reference types that are non-JS primitives instead (i.e. Bool, Int, Num, Str), which better matches both JS (1 instanceof Object === false) and Wasm (i31ref </: $Object) semantics. I think this is an improvement over what we have right now.

What is $Object?

But otherwise, yes.

@Derppening

Copy link
Copy Markdown
Contributor Author

Changing Num <: Anything would change the meaning of Object - Right now, Object is the top type of all reference types, and as such Int <: Object.

I don't understand your poitn (is it really your point or some other LLM nonsense?). Int is not meant to be a "reference type" from the user's perspective (how it gets compiled to WASM is irrelevant, here).

Yes that's my point - You asked what the possible drawbacks are if we switch Num and Bool to be direct subtypes of Anything rather than Object, and so I was trying to explain that as things stand right now Object is the supertype of all reference types, and as far as I understood it up to this point Int should be considered a reference type - hence my point about how Object would change to mean reference types from the user's perspective (i.e. all JS reference types except Int, Num, Bool, and Str) if we have Num and Bool direct subtypes of Anything.

Thank you for your clarification - I will change the erased type hierarchy to match this new meaning of Object.

If we instead make Num <: Anything (and therefore Int <: Anything), it would make more sense if we instead define Object as the top type of all reference types that are non-JS primitives instead (i.e. Bool, Int, Num, Str), which better matches both JS (1 instanceof Object === false) and Wasm (i31ref </: $Object) semantics. I think this is an improvement over what we have right now.

What is $Object?

But otherwise, yes.

I meant to put (ref null $Object) there instead, which is the base class for all user-defined classes in Wasm (currently only containing the class tag $$tag as far as I can recall).

@LPTK

LPTK commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

The thing I realize now is that an Anything type that subsumes all types is simply not compilable to targets like WASM without some sort of auto-boxing (which I think we should avoid – boxing should be an explicit operation in the IR). What happens today when we try to compile a call to a function that accepts Anything and to which we pass an Int32?

So, I think we actually want a hierarchy without a common top type, and the LUB operation would be partial, returning Either[Incompatible, ErasedType], where Incompatible describes the type incompatibility error preventing compilation.

As for the for LUB of Object and Int, we just need another name. Not Anything, which will be the true top type in the static user-facing type system before specialziation and lowering (Anything won't exist as an erased IR type). I think a name like Unknown makes sense – a value that can be represented uniformly, but we can't know what it is.

By the way, Unknown is also where things like value classes and compact enums will be represented (in WASM, this will be as an i31ref), to avoid boxing. The main difference between Object and Unknwon is that the former admits an identity that can be checked at runtime, whereas we can't pattern match or otherwise observe the identity of Unknwon directly (though we can downcast it in the IR – but not in the user-facing type system as that would be unsound).

@Derppening

Derppening commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

TODO:

  • Make Cast a Path and remove castToPath, castTo doesn't need a callback anymore
  • Rework the erased type hierarchy based on Implement Typed IR #504 (comment)
  • Make arrays and tuples typed (tuples should be arrays)
  • Hoist
          case _ if wasmIntrinsic.isDefined =>
          val (sym, nmePath) = wasmIntrinsic.get
    into its own pattern matching, and possibly early return (instantiate is unused in the arm)
  • Add a tailrec test that returns mutually incompatible types (Int and Int32)
  • Suppress wasm is a virtual module if compiling wasm using CompilationTarget.Wasm
  • Cache parents of a symbol (see https://github.com/LPTK/mlscript/tree/wip-inheritance-sets)

| (Celsius as c) => c

Celsius(0) is ToFahrenheit(Fahrenheit(32))
Celsius(0.0) is ToFahrenheit(Fahrenheit(32.0))

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 all these literal changes have no effect and should be reverted. 0 is a proper Num value.

case td: TypeDef => // * Type definitions are erased
blockImpl(stats, res)

// TODO(Derppening): Functions are hoisted ahead of `rest` so mutually-recursive definitions resolve. A consequence

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.

TODO to address?

val t2 = new Tree.Ident("arg2")
val p1 = Param(FldFlags.empty, VarSymbol(t1), N, Modulefulness.none)
val p2 = Param(FldFlags.empty, VarSymbol(t2), N, Modulefulness.none)
val p1 = Param(FldFlags.empty, VarSymbol(t1, erasedType = N), N, Modulefulness.none)

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.

Use the correct erased argument type for this builtin.


type TypeSymbol = BaseTypeSymbol | TypeAliasSymbol

extension (sym: TypeSymbol)

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.

Extension has nothing to do here.

case Tup(_, _) => Set.empty
case Field(_, _) => Set.empty

/** A primitive type of the block IR. */

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.

Move all these additions to an ErasedType.scala file.

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.

4 participants