Implement Typed IR - #504
Conversation
`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.
There was a problem hiding this comment.
Still outstanding, but I am unsure how to recover the erased type here so I'll leave this for now.
|
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.
|
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. |
|
|
(In response to comments for c1a9cd7)
I tried to replace the FYI the two test failures are:
|
|
(In response to comments for 98d83af)
All preserved - The branch is on 6b13fa7 + a commit containing the remainder of
Sorry, once again GitHub swallowed your comments on the commit and I'm not sure where you put it. Do you mean the |
|
|
||
| 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) |
There was a problem hiding this comment.
Claude seems to flag this as the wrong location for the change + a latent bug:
These aren't just untyped. The
private val captureSymcan't override the trait's, so it's a
second symbol:appendCaptureField/initCaptureFieldbind toClsLikeRewrittenScope's
captureSymand declare/assign the field, while the subclasscapturePathreads through the
private copy. They agree only because both spellTree.Ident(obj.nme + "$cap")— by name, not
identity. The compiler says so if you disturb it: switching toprivate lazy valgives
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 thesymit uses (obj.cls.isym/obj.clsBody.isym),
so they're duplicates of 893/894. Pre-existing onhkmc2, filing separately.Worth noting nothing in the suite exercises a class-like scope's own capture — every
$capin
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) |
There was a problem hiding this comment.
Same comment from Claude as #504 (comment).
| ( | ||
| VarSymbol(Tree.Ident(nme)), | ||
| TermSymbol(syntax.LetBind, S(obj.cls.isym), Tree.Ident(nme)) | ||
| VarSymbol(Tree.Ident(nme), erasedType = N), |
There was a problem hiding this comment.
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
isdata.getNode(i)'s capture class viactx.rewrittenScopes, notthis.captureClass. And
capSymsMap_is an eagerprivate val, so typing it reaches into another scope's lazy
captureInfoduring construction — an init-order dependency I'd rather not introduce as a
drive-by. The twin inLiftedFunc(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.
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
Addressed in c26937d - Give it a look and see if that's correct.
Yes. Why remove the config command? |
|
(In response to comments for 4d5e4f8)
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 😢 |
Reinstated in 5f7fdbf. |
Don't forget to open an issue, then 😉 |
Addressed. |
|
Comments for 5f7fdbf:
It refers to the other variant of the test block, which has the same body but without the Comments for d4e6778: Addressed in 95ee9cb and 7b901ab - the first commit applies your suggestion to just type paramless |
Opened #547, #548, #549, #550, and #551 for each item listed in #504 (comment). |
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 |
There was a problem hiding this comment.
This logic seems a bit messed up.
I think what's happening is that two cases are being conflated:
- Class/object/module
funmember are compiled to getters and genuinely do not have any parameter lists. - Local
funbindings always need at least one parameter list, so a parameterless localfun fis actually compiled tofun f(). The erased type should match what something is compiled to – so in this localfuncase, the erased type should contain the implicitly added parameter list.
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
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 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 onlyever replaces
Unknownwith 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.
Anythingis 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 unboxedi32cannot becompiled 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.
Unknownis a value with a uniform representation -anyrefon Wasm, any value on JS - whose identityis not known statically, though it can still be tested at runtime:
if x is Intlowers to an ordinarymatch. It is not surface syntax and carries no type symbol. An unresolvable alias and a writtenAnythingareUnknown; a slot with no annotation has no type at all, and folds toUnknownwhen read.Objectis the part of that space that excludes the host primitives: user classes and modules, arraysand tuples, and functions. Both backends already agreed on the exclusion (
1 instanceof Objectisfalseon JS; an
i31refis not a subtype of the$Objectstruct on Wasm), soBool,Str,NumandIntnowsit beside
Objectrather than under it. The exclusion walks the parent chain rather than testing a fixedsymbol set, so it stays descendant-closed: a user class extending
Intis excluded along withInt.Unknown- because they have no uniformrepresentation. They are the only types eligible for unboxed lowering; so far only
Int32is. A primitiveis 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.
Int32andIntare therefore unrelated despite both beingintegers; creating an
Int32, and converting between it and the boxed integers, go through the Wasmintrinsics.
Int31 <: Int <: Num, so an integer literal flows into aNumslotas a widening - no cast, no error.
FuncRefis a signature, not a value type, which is why it sits outside the hierarchy rather thanunder
Object. It carries an annotated function's parameter lists and result, so curried and partiallyapplied 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
Functionreference type instead, which is underObject.Least upper bounds are partial.
ObjectandInthaveUnknownas their bound;Int32andStrhavenone, since no single representation admits both. Such a pair yields an
Incompatibletype, which absorbsevery 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
$Objectfamily, and function values are only backed by a closure struct when the optional first-classfunction pass runs. So
Objectstill lowers toanyrefthere rather than to the$Objectstruct type.Casts
A
Castrepresents coercing a value into a slot of a different type. One decision procedure governs it:CastCastThe 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
Incompatibletype is not unrelated to the slot but unrepresentable in itself, so its messagenames 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 toan 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.
import, cyclic parent chain) the value's type is treated as
Unknownfor that decision only and aconservative 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.
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.
Every cast the IR can build is testable.
Objectis tested like any other class -globalThis.Objectagreeswith 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
Castis built. The remaining "notest can be expressed" arm exists for exhaustivity and carries a
softTODO: reaching it leaves the cast asit 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 oneas!!(asserted with, and without, aruntime check).
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.
The payoff is visible in
wasm/Casts.mls: the backend's own "Cannot cast a Wasm value of typei32to(ref null any)in a different type hierarchy", which carries no source location, now appears directlybelow 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.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
Array; records and lambdas carry notype of their own.
Int -> Intis ordinary surface syntax and is accepted on aval, parameter or declared result, but the slot ends up as untyped as an unannotated one: any referencevalue 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
Functionreference type would fit, and is the intended direction.Annotating the slot
Functioninstead does narrow, and does raise the unrelated-type error.slot always emits a cast. Deliberate - narrowing from
Unknownis a genuine downcast - but it makescasts more frequent than the program strictly requires.
TailRecOptforgets 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
:fixmeinwasm/Casts.mls.Int32has a Wasm lowering.Int64,Float32andFloat64are in the hierarchy and obey theprimitive rules, but the backend registers no value type for them, so they never become
i64,f32orf64- a slot declared at one falls back toanyref. No intrinsic constructs such a value either, sonothing can legally reach that slot.
Incidental fixes and tooling
Fixes to pre-existing behaviour:
tail calls.
reconstructing their path during lowering.
New diagnostics unrelated to typing:
wasm.*intrinsic while targeting JS now reports "WebAssembly intrinsics are not available whentargeting 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.jsfork dependency: the publishedbinaryennpmpackage now accepts a feature set when parsing WAT.