Repository navigation
compiler: real npm typescript 5.8.2 throws Cannot convert undefined or null to object inside InferredProject.addRoot — blocks the typescript binding removal #10772
Description
Summary
Compiling the real npm typescript package (5.8.2) from source produces a binary that throws partway through transpileModule:
TypeError: Cannot convert undefined or null to object
at _IdentifierNameMap__class_expr_648.get (<anonymous>)
at new __AnonShape_1ac8eab1a279efdb (<anonymous>)
at InferredProject2.addRoot (<anonymous>)
All three frames are inside real typescript.js — IdentifierNameMap, InferredProject.addRoot are TypeScript's own services-layer internals, not perry stdlib. The mangled names (__class_expr_648, __AnonShape_…) are perry's lowering of a class expression and an anonymous object shape in that source.
The throw is reached from a second transpileModule call made with reportDiagnostics: true. The first transpileModule in the same program returns byte-correct output.
Why this has never been seen before
crates/perry/src/commands/compile/well_known.rs states the routing rule plainly:
perry routes a bare
import 'X'to the bundledperry-ext-Xwrapper even when anode_modules/Xcopy is on disk
So today every import ... from "typescript" is answered by the perry-ext-typescript Rust shim (the audited transpileModule/flattenDiagnosticMessageText subset, #8511). Real typescript.js is never compiled or executed. This report is the first time perry has run that code.
That also means the defect is not a regression — it is pre-existing and was simply unreachable.
Impact: it blocks the typescript binding removal specifically
Surfaced by #10765, the native-binding removal campaign PR. typescript was in that PR's original seven and has been pulled back out; the other six (qs, fastify, dayjs, date-fns, rate-limiter-flexible, node-cron) removed cleanly and shipped.
The campaign's premise is strip the bindings, fix the compiler. For typescript the two steps simply have to land in the other order: today import 'typescript' works for users because the shim answers; removing the binding before this is fixed turns a working path into a throwing one. The binding stays until then.
Reproduction
The binding still intercepts import 'typescript', so you must tell perry to AOT-compile the real package. Perry itself prints the instruction when it declines:
note: serving 'typescript' from the bundled native binding 'perry-ext-typescript' (a partial drop-in), ignoring your installed node_modules/typescript. Add 'typescript' to perry.compilePackages to AOT-compile the real JS instead.
mkdir ts-repro && cd ts-repro
npm init -y && npm install typescript@5.8.2package.json needs both keys — compilePackages alone is refused by the #497 host opt-in gate:
{
"perry": {
"compilePackages": ["typescript"],
"allow": { "compilePackages": ["typescript"] }
}
}// probe.ts
import ts from "typescript";
const a = ts.transpileModule("const x: number = 1;", {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 },
});
console.log(a.outputText.trim()); // OK
const b = ts.transpileModule("interface I { a: string } const y: I = { a: 'q' }; export default y;", {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 },
reportDiagnostics: true, // <-- throws
});
console.log(b.outputText.trim());perry probe.ts -o probe.bin && ./probe.binnode --experimental-strip-types probe.ts prints everything. Perry prints the first output, then throws.
Confirm the real package was actually compiled — the log must say Compile package: typescript and report hundreds of modules. If it says Found 2 module(s), the binding answered and you are not testing this bug.
Note PERRY_DISABLE_WELL_KNOWN=1 does NOT work as a bypass here — it leaves typescript on the native shim (Found 2 module(s), _js_typescript_transpile_module in the link). It looks like it should bypass the flip and does not; use compilePackages + allow.
Originally observed while compiling with the typescript binding deleted (the first revision of #10765), which is the same code path by a different route.
Environment: perry v0.5.1608, macOS arm64, Node 26.5.1 (the .node-version pin), typescript@5.8.2 exact. The compile is slow — typescript.js is a single ~10 MB module that codegen partitions into 53 LLVM units (~25 min on a contended 10-core box).
Open question: narrow trigger or broad one?
Is reportDiagnostics: true the trigger, or is it the second transpileModule call at all? That distinction decides whether this is a diagnostics-path bug or something about repeated invocation / shape reuse, and it should be settled first.
The isolation run is the same probe with reportDiagnostics: true removed and nothing else changed:
const b = ts.transpileModule("interface I { a: string } const y: I = { a: 'q' }; export default y;", {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 },
});If that still throws, the trigger is the second call; if it passes, the trigger is the diagnostics path.
This is not answered, and no answer is coming from me — do not wait for a follow-up comment. Four attempts were made and each was invalidated before producing a verdict:
- the host rebuilt the compiler underneath the running probe;
PERRY_DISABLE_WELL_KNOWN=1silently left the binding in place, so the run measured the shim (Found 2 module(s)) rather than real source;perry.compilePackagesalone was refused by the security: host-app allowlist forperry.nativeLibraryandperry.compilePackages#497 host-opt-in gate;- the final pair got through codegen (53/53 LLVM units) but then entered a fresh auto-optimize runtime+stdlib rebuild, and the session ended before the link.
Each arm is roughly 25 minutes of codegen plus a full auto-optimize rebuild, so this is an hour of wall time per attempt on a contended box — budget for that rather than expecting a quick check. Everything needed to run it is above: the working two-key package.json, the one-line probe change, and the liveness check that catches the failure mode which wasted attempt 2.
Methodology note, because it cost real time here
While triaging a different failure in the same session, a dependency-free reproducer was used as a discriminator: "if a fixture that never mentions the package also fails, the removal is not the cause." A three-line import http from "node:http" did fail identically, which correctly showed the removal was not at fault — and was then very nearly read as proving a real compiler defect.
It was not one. The actual cause was running the perry binary from outside its worktree, so auto-optimize could not build libperry_ext_http.a. The compiler said so in its own log:
note: Perry workspace source not found — linking the prebuilt full stdlib.
warning: `http` needs libperry_ext_http.a, which is not on disk.
Setting PERRY_WORKSPACE_ROOT made the same fixture pass. The discriminator answered the question it was designed for ("not the removal") and was mistaken for answering a different one ("therefore a real defect"). The tell was a log line the discriminator did not look at. Worth remembering when a dependency-free repro comes back positive: it rules something out, it does not rule anything in.
Activity
proggeramlug commentedon Sep 20, 2026
Surfaced by #10765, which originally removed this binding along with six others. The other six shipped;
typescriptwas pulled back out and its binding retained until this is fixed.