Implement Typed IR - #504
Conversation
This will be replaced with `Symbol.erasedType`.
`Call`, `Lambda`, `Select`, and `DynSelect` is left for a future commit.
LPTK
left a comment
There was a problem hiding this comment.
Some possible next immediate steps:
- Update printer to show the erased types at variable and member declaration/definition sites.
- Update
Loweringso it generates erased types from parameter type annotations, to be used to annotate the correspondingVarSymbol. - 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.
| 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)) |
There was a problem hiding this comment.
It seems many cases like this one should carry over the previous erasedType somehow.
|
The current task list (work items created by me, organized by AI):
|
What does that mean?
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! |
Good point, I have updated to task list. |
Refinement of types during Lowering is implemented later.
…to enhance/typed-ir
…to enhance/typed-ir
…to enhance/typed-ir
|
The changes should be all ready. Note that:
|
What's the rationale for this? |
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
I suspect this is LLM gibberish devoid of substance. |
It should be |
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.
Making Changing Note that Let me know if this direction is a better approach than the
Yeah... That's a whole lot of nothing.
Done in bc2c57f, changed checked and unchecked casts to print as |
I don't understand your poitn (is it really your point or some other LLM nonsense?).
What is But otherwise, yes. |
Yes that's my point - You asked what the possible drawbacks are if we switch Thank you for your clarification - I will change the erased type hierarchy to match this new meaning of
I meant to put |
|
The thing I realize now is that an So, I think we actually want a hierarchy without a common top type, and the LUB operation would be partial, returning As for the for LUB of By the way, |
|
TODO:
|
| | (Celsius as c) => c | ||
|
|
||
| Celsius(0) is ToFahrenheit(Fahrenheit(32)) | ||
| Celsius(0.0) is ToFahrenheit(Fahrenheit(32.0)) |
There was a problem hiding this comment.
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 |
| 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) |
There was a problem hiding this comment.
Use the correct erased argument type for this builtin.
|
|
||
| type TypeSymbol = BaseTypeSymbol | TypeAliasSymbol | ||
|
|
||
| extension (sym: TypeSymbol) |
There was a problem hiding this comment.
Extension has nothing to do here.
| case Tup(_, _) => Set.empty | ||
| case Field(_, _) => Set.empty | ||
|
|
||
| /** A primitive type of the block IR. */ |
There was a problem hiding this comment.
Move all these additions to an ErasedType.scala file.
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
Castnode makes every narrowing explicit.The motivation is the Wasm backend, which previously represented everything as
anyrefand re-establishedtypes with a runtime cast at each use site. It can now declare struct fields and function signatures at their
real types, keep
Int32unboxed as a nativei32, and cast only where a narrowing genuinely occurs. JSbehaviour is unchanged: it erases unchecked casts.
The typed IR
Types come from annotations only — parameter and return signatures,
val/class-parameter/fieldannotations, 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.
Anythingis the true top, above primitives and functions alike;Objectis the base of allreference types.
<: Anythingand nothing else — in particular not<: Object. They arethe only types eligible for unboxed lowering; so far only
Int32is actually lowered that way.Int31 <: Int <: Num, so an integer literal flows into aNumslotas a widening — no cast, no error.
representable. A function becomes a
Functionreference when used as a value.Int32andIntsit on opposite sides of the boxed/unboxed divide and are therefore unrelated: convertingbetween them is a compile error, not a cast. Creating an
Int32, and converting between it and the boxedintegers, 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.
Objectas a type andObjectas a runtime test deliberately disagree. As a type it is the base of allreference types, so
fun f(): Object = 1type-checks; as a runtime test it compiles to a host-level checkthat primitively represented values fail, so
1 is Objectisfalse. This matches existing behaviour onboth 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
ObjecttoAnything.Casts
A
Castrepresents coercing a value into a slot of a different type. One decision procedure governs it,with three outcomes:
CastCoercions 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 anargument to an annotated parameter; and returning from a merged tail-call dispatcher (which declares the LUB
of the return types it merges).
Invariants
the slot's type, or of a subtype of it, passes through unchanged.
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.
would pollute LUBs, printing and identity.
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.
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
checkCastsflag expands them into a runtime type test that throwsCannot narrow a value to type 'X'on failure. The expansion runs last in the pipeline, so no later passcan 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 theunboxed 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
asand an unchecked oneas!(asserted, not verified). Neither issurface syntax.
Wasm backend
Consuming the IR's types rather than falling back to
anyref:Int32is lowered unboxed as a nativei32;changes a primitive parameter or result type — neither is expressible in Wasm's type system.
Testing
codegen/ErasedType.mlsandcodegen/CheckedCasts.mlsare the dedicated test files, withwasm/Casts.mlscovering both on Wasm. The new
:siretdirective makes the IR printer show erased types alongside:sir/:soir. Cast tests use:noInlinewhere the inliner would otherwise see through the opacity functionthat makes the coercion necessary.
Non-goals / known limitations
Arraywas 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.
val, parameter or declared resultannotated
Int -> Inttakes no coercion and cannot raise the unrelated-type error. Erasure yields avalue type and a function type is not one; erasing to the
Functionreference type would fit, and is theintended direction.
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.
Incidental fixes and tooling
Fixes to pre-existing behaviour:
tail calls.
The Wasm test harness also drops the
hkust-taco/binaryen.jsfork dependency: the publishedbinaryennpmpackage now accepts a feature set when parsing WAT, the only thing the fork's extra entry point provided.