fix(hir): expand type aliases in the inferred type of new C<...>() so the generic specialization resolves (#7848) - #7852
Merged
Conversation
added 2 commits
August 11, 2026 15:58
…so the generic specialization resolves A generic class instantiated with a type ALIAS argument silently lost its monomorphized specialization at every use of the binding, so the emitted dispatch guard was compiled against the generic TEMPLATE class and could never pass. Two lowerings read the same `new C<...>()` type-argument list and only one expanded aliases: `lower/expr_new.rs` builds the `New`'s `type_args` with `extract_ts_type_with_ctx(t, Some(ctx))` -- which monomorphization keys the specialization on -- while `lower_types.rs`'s inferred declared type used the context-free `extract_ts_type(t)`. Codegen re-derives the specialization from the declared type and missed, falling back to the template class. Refs #7848
📝 WalkthroughWalkthrough
ChangesGeneric alias specialization
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant NewExpression
participant TypeLowering
participant Monomorphization
NewExpression->>TypeLowering: Extract generic arguments with lowering context
TypeLowering->>Monomorphization: Provide alias-expanded specialization type
Monomorphization-->>NewExpression: Produce matching class specialization
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Without it `lower_module` leaves every `Expr::New::class_name` at the generic BASE, so all seven cases failed identically -- including the class / interface / builtin controls that were never broken, which is the tell that the harness and not the compiler was wrong.
proggeramlug
pushed a commit
that referenced
this pull request
Aug 11, 2026
This was referenced Aug 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #7848.
What was wrong
A generic class instantiated with a type argument that is a type alias silently lost its monomorphized specialization at every use of the binding. The emitted dispatch guard was compiled against the generic template class while the object is an instance of the specialization, so the guard could never pass and every method call on that binding took the fully dynamic
js_native_call_method_by_idpath — permanently, for the life of the program.Output stayed correct; exit code stayed 0; nothing went red. The only symptom was speed.
Two locals, same class declaration, adjacent lines, and only one got a fast path.
Root cause
Two independent lowerings read the same
new C<…>()type-argument list, and only one of them expanded type aliases:lower/expr_new.rs:1216— theNew's owntype_args, which monomorphization keys the specialization onextract_ts_type_with_ctx(t, Some(ctx))lower_types.rs:604— the INFERRED declared type of the bindingextract_ts_type(t)(=…_with_ctx(t, None))extract_ts_type_with_ctxresolves a type alias only whenctx.is_some()(lower_types/extract.rs, theTsTypeRefarm).Codegen re-derives the specialization from the declared type and misses (
type_analysis/predicates.rs:312):mangle_typemapsFunction(_) -> "fn"butNamed(n) -> n, so the declared type soughtRegistry$Stage_numwhile the class that exists isRegistry$fn_num.It killed BOTH fast tiers, not just the guard
lower_call/property_get/dynamic_dispatch.rs:1132:The binding did carry a correct Phase-3b
Ptr<Shape>provenance fact namingRegistry$fn_num. The declared-type resolution namedRegistry. They disagreed, so the guard-free arm was rejected and the site fell through toemit_guarded_direct_method_call— whose guard then tested class 1 against a class-1004 receiver.The fix
One call, in
crates/perry-hir/src/lower_types.rs: the inferred declared type ofnew C<…>()now lowers its type arguments withextract_ts_type_with_ctx(t, Some(ctx))— the identical call, on the identical AST nodes, thatlower/expr_new.rsalready uses to build theNew'stype_args.Safe by construction: the inferred declared type can no longer name anything the
Newdoes not.ctxdoes exactly two things in that function — resolve a type-parameter reference and resolve a type alias — and both are already applied at theNew.★ Which tier this routes to
Deliberately stating this, because a declared type is a hint and not a proof (#7846), and getting it wrong here would turn a dead-fast-path bug into a wrong-answer bug.
emit_guarded_direct_method_callstill emits a real runtimejs_method_direct_shape_guard(recv, class_id, keys_token). The fix only changes which class id it tests — from one that could never match to the one that does. A wrong declared type still degrades to a failed guard, never to a wrong result.Ptr<Shape>provenance fact (collectors/ptr_shape.rs: the local holds exactly onenew <class>for its whole lifetime, and containment proves no alias exists). The declared type appears there only inside an equality test against that fact, so it can never introduce a class the fact does not already assert — it can only agree or disagree. Disagreement falls back to the guarded tier, which is exactly today's behaviour.byKind/byStr) takes on unmodifiedmaintoday. This change makes the aliased case behave identically to the already-shipping non-aliased case.Tests
crates/perry-hir/src/lower_types/generic_alias_specialization_tests.rs— a lib unit test module (so it runs in the per-PRcargo-testjob, not only nightly).It asserts the property codegen actually depends on, as an equation rather than as a spelling: for
const r = new C<…>(), the namegenerate_specialized_namederives from the binding's declared type must equal the class theNewwas rewritten to. A future change tomangle_typeor to the naming scheme therefore cannot make the tests pass while the two sides drift apart again.Every alias arm is covered rather than a sample of spellings — the four that were broken and the three that always round-tripped:
type S = stringReg$S_numReg$str_numtype Stage = (n) => nReg$Stage_numReg$fn_numtype O = { a: number }Reg$O_numReg$obj_numtype U = string | numberReg$U_numReg$union_str_num_numclass CReg$C_numReg$C_numinterface IReg$I_numReg$I_numstring(builtin)Reg$str_numReg$str_numBenchmark probe
gc-handoff/bench/generic_alias_dispatch.tsandgeneric_alias_dispatch_ctl.ts— the same program, differing in one word: the type argument spelled through the alias vs spelled inline.The control is what makes it a measurement rather than a story, and the probe was verified to exercise its subject before it was trusted (three probes in this campaign measured nothing while their bug was fully intact). On unmodified
main, in emitted IR:Both print
13495500 3, matchingnode --experimental-strip-types.Validation
Baseline arm =
$HOME/cargo-targets/final/release(perry 0.5.1467 @0321c6554, from aworktree verified clean at that commit). Fix arm =
$HOME/cargo-targets/pipe/release.The change is in the binary — checked before trusting any A/B, because my first build
process died silently and a compiler without the change makes both arms behave identically
and reads as a clean "zero regressions".
m0810/genalias.ts, IR ofmain:js_method_direct_shape_guard(…, i32 1)js_native_call_method_by_idReg__*$pshape(dead arm)u_Reg_24_fn_5f_num__*$pshapeandu_Reg_24_str_5f_num__*$pshapeCorpus (23 programs incl. the two probes and the two repro files): exit 0 and
byte-identical to
node --experimental-strip-typeson both arms.cmpof the two arms' executables — 20 identical, 3 differ:The three that differ are exactly the three programs in the set that instantiate a
generic class with a type alias. Byte-identical programs cannot regress, so no host time is
needed for them.
generic_alias_dispatch_ctl(same program, type spelled inline) andgenmono(hand-monomorphized) are byte-identical, which is what makes the probe acontrolled measurement.
★ Note for anyone repeating this: holding the output basename constant is necessary but
not sufficient. Two
perrybinaries built in twoCARGO_TARGET_DIRs link two separatelybuilt
libperry_{runtime,stdlib}.a, and those are not byte-identical across target dirs —which alone makes every program differ. My first sweep read 0/23 identical for that reason.
gc-handoff/m0810/build_pipe.shnow takesRUNTIME=separately fromPBIN=so acompiler-only A/B can pin one runtime for both arms.
Unit tests:
cargo test --release -p perry-hir --lib generic_alias— 7 passed, 0 failed.★ The first run of those tests failed all seven cases including the three controls
(class / interface / builtin) that were never broken. That is the tell that the harness,
not the compiler, was wrong:
lower_modulealone leaves everyExpr::New::class_nameatthe generic base, because rewriting it to the specialization is
monomorph::update_call_sites' job. The harness now callsmonomorphize_module. Recordedbecause it is the same shape as the probes that measured nothing earlier in this campaign —
the difference is only that this one had controls in it, so it announced itself.
Timing: the shared measurement host has been continuously locked by another agent, so
absolute seconds are not in this PR. The structural evidence above is independent of it —
the byte-identical/differs split is exact and needs no host — and the coordinator's
independent back-to-back run on
m0810/genalias.tsvs a hand-monomorphizedgenmono.tsput the gap at ~1.44x (0.0823 vs 0.0571 s), taken under load and explicitly flagged as
indicative rather than final.
Measured (quiet M1 mini, best-of-5 interleaved, exit-checked)
LOAD_BEFORE=2.10 LOAD_AFTER=2.20 PROC_AFTER=0 VERDICT: CLEAN. Every cell exit 0.0321c6554The noise floor is measured, not assumed. The 20 programs that are byte-identical
between the arms ran 0.982–1.003 (median 0.999); a byte-identical binary cannot change, so
that spread is this run's noise. The three movers are an order of magnitude outside it.
The pairs meet
Each is a program pair identical apart from the bug, so the fix is right exactly if the pair
converges:
genaliasvsgenmono(hand-monomorphized equivalent)generic_alias_dispatchvs_ctl(type spelled inline)genaliaslands ongenmonoto four decimal places — the aliased generic now costs exactlywhat the hand-written monomorphic class costs.
No regression
All 18 corpus ceilings met: churn 0.2889 · churn_alloc 0.2411 · push_cls 0.2361 ·
push_num 0.0695 · churn_read 0.0224 · cycles 0.1114 · deeplist 0.1225 · tree 1.1616 ·
tree_wide 1.6503 · retain 0.3503 · retain1 0.1362 · retain_wide 0.4599 · retain_wide1 0.1591 ·
fib40 0.3936 · asyncpipe 0.1338 · shapes 0.1838 · interp 1.2364 · iso_miss 1.6705.
Canary
iso_missprintschecksum 437840 misses 0.