Skip to content

Read the value a called function assigned where TypeScript still says undefined - #3310

Merged
ctate merged 18 commits into
mainfrom
fix/js-ctor-arg-undefined
Nov 7, 2026
Merged

ctate merged 18 commits into
mainfrom
fix/js-ctor-arg-undefined

Conversation

@cramforce

@cramforce cramforce commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

The bug

A compiled program silently computed something else than Node, with exit 0 and no diagnostic. mustache@4.2.0, compiled natively, rendered the empty string for every template. Eleven lines reproduce it without a package:

function Holder() {}
Holder.prototype.show = function (value) {
  return typeof value + " " + String(value);
};
var text;
function assign() {
  text = "t";
}
assign();
console.log(new Holder().show(text)); // Node: string t    main: undefined undefined

Fully typed TypeScript had it too:

let start: number | undefined = undefined;
const set = (): void => {
  start = 2;
};
set();
console.log("abcd".slice(start)); // Node: cd    main: abcd

The mechanism

TypeScript types a read by the last assignment it sees in the read's own function, and a call does not reset that (microsoft/TypeScript#9998). In both programs it types the read undefined. lowerStaticallyUndefinedArgument asks "is this argument's type undefined?" for every optional or defaulted argument, and its callers then keep the argument only for its effects and continue with "absent". A variable read has no effects, so it was dropped. #591 put the same question into the dynamic method call, which is how a method of a constructor function's instance is called; a class method or a plain function reads its argument, which is why only the old-style object showed it.

The stale-narrowing analysis (NarrowingStability) already decides when a call may have undone a narrowing. It restored the declared type in the positions that can take it and left every other read to a checked extraction. A narrowing to nothing but undefined or null has no payload to check, so nothing protected those reads.

The change

One rule added to that analysis: a narrowing to nothing but undefined or null that a call may have undone reads as declared in every position, whatever the declared type is. For a binding the checker types by flow alone (var x;) the declared type is any, which is what the checker itself answers for a read from a nested function. Other narrowings keep their rule.

Three places used a type the checker inferred from such a read:

  • An untyped copy (let y = x) is "nothing" to the checker as well. It now has the declared type of the read it was initialized or assigned from, at its declaration and at every read, until an assignment of its own that every path to the read runs stores that nothing again (y = undefined; return y returns nothing, as before; a reset on one path only, or y = null / y = "text" into a JavaScript binding the checker keeps typing undefined, still reads the stored value).
  • An argument whose value can be the read (s.slice(c ? n : undefined), a parenthesized read) is no longer folded to "absent".
  • A function whose return type the checker inferred as undefined from the read (function f() { x = undefined; fill(); return x; }, (() => x)()) returned nothing. Its callers' types come from the same inference, so the compiler cannot carry the value out. It is now refused by name: SC1090, "returning 'x' from a function whose return type TypeScript infers as 'undefined'", with the hint to declare the return type. With the declaration the return carries the stored value. The limitations page says so.

The family, measured

One program per (source kind × scope × binding × assigner), one line per read position, each compared with Node: .mjs, .cjs and .ts; module level, function level and properties; 13 kinds of binding; 10 ways a function assigns it; 51 read positions. 330 programs, 15,749 cells, both compilers built from the same tree (main e8c933e and this change on it).

right silently wrong loud refused at build
main e8c933e 13,226 1,416 464 643
this branch 14,556 0 512 681

Of the 1,416 silently wrong cells, 1,244 are right, 96 throw a deferred SC2020 (array.slice(start) with an untyped start in JavaScript, which the wrong fold used to hide), and 76 are refused at build (62 inferred-undefined returns, 14 array.slice with an untyped start in TypeScript's let x;). 48 loud and 38 refused cells became right as well. No cell that is right on main is anything else here.

The silently wrong positions on main were the argument of a constructor function's method (JavaScript), Number(x), slice, indexOf, split, padStart, join, push, a conditional or parenthesized argument (JavaScript and typed TypeScript, bindings and class fields alike), and in TypeScript a function or arrow returning such a read. A function expression called where it stands was never affected: the checker follows it.

A second set of 31 positions (a second optional argument, substring, charAt, at, array indexOf with a start, fill, splice, repeat, padEnd, copies, x!, x as T, x++ and others; 840 cells): 140 silently wrong on main, none here.

What is left is loud or refused by name, on main as well: an array or object literal holding such a read in JavaScript (the literal's element type is the checker's), member reads on a flow-typed binding (TypeScript's 2454, refused as SC1090), array.slice and toFixed with an untyped argument (SC2020, SC2012).

Tests

  • tests/corpus/9641-callee-assigned-reads.mjs, 9642-callee-assigned-reads-commonjs.cjs, 9643-callee-assigned-reads-typed.ts: the family in the three source kinds, including the parser shape mustache uses.
  • tests/corpus/9646-copy-reset-paths.mjs: an untyped copy and an assignment of its own on some or all paths to the read, each shape both ways: a conditional assignment then a reset, a reset in both arms and in one, under &&, ||, ??, ?: and ?.(), after an early break, return and throw, in a loop body, a var and a let declared in a loop body, in try, catch and finally, in a switch, behind a labeled break, by a closure, through destructuring, null and a value of another type, and the copy assigned from itself (c = keep ? c : undefined, c = c ?? x, c ||= x, c = same(c), a swap).
  • tests/corpus/9645-mustache-templates/: the published mustache@4.2.0 (vendored unmodified with @types/mustache), compiled natively, rendering sections, inverted sections, partials, custom and switched delimiters, standalone lines, escaping, parse and its three error messages. With the package forced into a Node island it matches Node too (the conformance lane picks the directory up; no entry in its list).
  • tests/diagnostics/stale-undefined-inferred-return.ts: the refusal.
  • Unit tests in narrowing-stability.test.ts, including the reviewer's six.

Review

Five bot findings were all about one sub-mechanism: when an untyped copy's own later assignment ends what its initializer left in it. d18b7a4, ffaa050, 538731b and c48fee9 (pushed to this branch) are merged and their tests kept; their rule, and the one this branch had, are replaced by one pass, because a rule per syntax form kept leaving a form out (on ffaa050, 9646-copy-reset-paths.mjs prints undefined where Node prints the value behind a labeled break, after a destructuring store, and after c = keep ? c : undefined). The pass carries one fact along the statements on the way to the read, "the copy may hold something else": a let declaration and an assignment statement set it (cleared only by a value spelled undefined, null or void e), a bare var declaration is no write, a block passes it through, an if joins its arms, and every other construct can only raise it. That list is closed: for any shape outside it, the read is of the stored value.

Proof

All in Vercel Sandboxes.

  • Zero-cost gate over the whole corpus (--gate-corpus=all, no allow pattern), this head (81f093e) against main b3ff410: 5,314 programs identical in IR, LLVM and release executable, 118 fail to build on both sides, none differ, none runtime-only. No benchmark, corpus program or npm-suite program contains such a read, so there is nothing to list. Compile time: benchmarks +0.48% [95% CI -0.60%, +1.35%] and fixtures +0.88% [-0.18%, +1.94%], within tolerance; self-compile +0.43% [-0.77%, +3.52%], which the gate calls inconclusive in this round (over the nine rounds run on this branch it was -1.03% to +1.23%, on both sides of zero). The candidate compiler builds its own source. The gate gave the same identity verdict on every earlier head.
  • Lanes, plain and sanitized (89 jobs, on the head before the review round, merged with main 2b1e84a): 22,233 and 18,981 tests pass. What failed is main's: the five island-value-matrix cells red since Read an instance's class through x.constructor, and compile static accessors #3165 (re-pin in Re-pin the island value matrix cells that read x.constructor #3298), and executable-cache.test.ts "every build and run option is in the cache keys" (Add --emit-lowered and --from-lowered: build from a lowered program #3218's three options have no rule from Key the executable cache by the tier policy, and require a rule for every build option #3260; fix in Give the lowered-program options their cache key rule (main fails this test) #3306). Two tests timed out in the plain lane only (runtime/test/regex.test.ts setup, native-toolchain.test.ts "TLS builds promote staged vendor archives") and pass alone and in the sanitized lane. unit-journal.test.ts failed in that round because a new memo was not in the lowerer's list; it is listed now and the test passes. The review round changed one function's rule; its unit tests, the six new programs (plain and sanitized, release and dev), lint, format and a full pnpm -r build were run again on the final tree.
  • Native bootstraps: plain and sanitized (self-hosting-native-driver), both pass (same round).
  • Test262: Function, Reflect, JSON, Object, Proxy: 0 regressed, 0 gained (same round).
  • The family table after the review round: unchanged (14,556 right, 0 silently wrong).
  • scripts/checker-identity.mjs --filter 964: tsgo and tsc-ts give the same LLVM IR for the new programs, release and dev. pnpm test:ts7 --baselines-only and the order-parity batches pass. Docs gate: 25 of 25.
  • The mustache program matches Node natively (plain and sanitized, release and dev) and with the package forced into a Node island.
  • pi, on the fleet's integration tree with the first version of this change applied (one-round build, for iteration only): 16 diagnostics before and after, no site gained or lost.

🤖 Generated with Claude Code

cramforce and others added 5 commits November 7, 2026 02:06
… undefined

TypeScript types a read by the last assignment it sees in the read's own
function, and a call does not reset that. After `var re; compile(); scan(re)`
or `let n: number | undefined = undefined; set(); s.slice(n)` it types the read
`undefined`. Every lowering of an optional or defaulted argument, and the
dynamic method call (how a constructor function's method is called), took
that as "the argument is absent" and dropped the read. mustache@4.2.0 rendered
the empty string for every template; typed programs sliced from 0.

The stale-narrowing analysis already decides when a call may have undone a
narrowing, and restored the declared type in the positions that can take
it, leaving the rest to a checked extraction. A narrowing to nothing but
`undefined` or `null` has no payload to check, so it now reads as declared
in every position, whatever the declared type is (`any` for a binding
typed by flow alone, as the checker answers for a read from a nested
function).

Two places used the checker's type of what surrounds such a read:

- an argument whose value can be the read (`s.slice(c ? n : undefined)`)
  is no longer folded to "absent";
- a function whose return type the checker inferred as `undefined` from
  the read returned nothing. Its callers' types come from the same
  inference, so it is refused by name (SC1090, "declare the function's
  return type"); with the declaration the return carries the stored value.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`let y = x` after a call that may have assigned `x`: TypeScript infers the
copy's own type from the narrowing, so `y` is "nothing" to it as well, and
`s.slice(y)` sliced from 0 in JavaScript (in TypeScript the declaration was
refused). A binding without a declared type that the checker inferred as
nothing from restored reads now has those reads' declared type, at its
declaration and at every read.

The question "is this read nothing to the checker?" is now asked of the
type itself, which is the one the lowering asks for next from the
checker's per-node memo, instead of a write census that an initialized
copy never appears in.

The mustache program prints its tokens by element, so its Node island way
matches Node too and needs no entry in the conformance lane's list.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
… memos

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
@vercel

vercel Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Nov 7, 2026 3:44pm UTC

cramforce and others added 2 commits November 7, 2026 05:04
`let copy = x; copy = undefined; return copy`: the copy inherited the
declared type of the read it was initialized from at every read, also
after an assignment of its own, so a function returning the reset copy
was refused (SC1090) although it returns nothing, as TypeScript infers.

A read of the copy now keeps the checker's type when the last
initializer or assignment that completes before it stores that same
nothing from a value that is not stale, is a statement of a block around
the read (every path runs it), and no stale one can run after it. A reset
on one path only, a reset followed by the read again, and in JavaScript
`copy = null` or `copy = "text"` into a binding the checker keeps typing
`undefined`, still read the stored value.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce and others added 3 commits November 7, 2026 05:14
Co-Authored-By: Claude Code <noreply@anthropic.com>
…the read

Merges d18b7a4 ("Address review feedback") and replaces its rule and the
one this branch had for the same finding with one pass, because both
decided by where a write stands in the text: d18b7a4 took a reset in the
right operand of `&&` for unconditional (the stored value was then
dropped by an optional argument), and kept a conditional assignment
before a definite reset.

A read of an untyped copy keeps the checker's "nothing" only when every
write of the copy that can reach the read stores that nothing from a
value that is not stale. A forward pass over the statements on the way
to the read carries one fact, "the copy may hold something else": the
declaration and an assignment statement set it from what they store, a
block passes it through, an `if` joins its arms, and every other
construct (a loop, try, switch, a label, a short-circuit or conditional
expression, a call's arguments, destructuring) can only raise it. Where
the pass cannot tell, the read is of the stored value.

tests/corpus/9646-copy-reset-paths.mjs runs each shape both ways against
Node; d18b7a4's two tests are kept.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce and others added 3 commits November 7, 2026 06:02
…ping

Co-Authored-By: Claude Code <noreply@anthropic.com>
Merges ffaa050 ("Address review feedback"), keeps its two tests, and
keeps this branch's pass in place of its rule: ffaa050 still read
`undefined` after `copy = keep ? copy : undefined`, after a reset behind
a labeled `break`, and after a destructuring store that follows a reset
(silently, in tests/corpus/9646-copy-reset-paths.mjs).

The pass now believes a reset only when the stored value is nothing by
its own spelling (`undefined`, `null`, `void e`, a declaration without a
value). Any other value the checker types as nothing may carry what its
type was inferred without: the copy itself (`y = keep ? y : undefined`,
`y = y ?? x`, `y = same(y)`), another copy, a swap. A judgement that
runs into its own computation is not kept, and its write counts as
stale. The corpus program has those rows, each both ways.

Co-Authored-By: Claude Code <noreply@anthropic.com>
538731b answers `copy = keep ? copy : undefined` in the rule the pass
replaced; the pass already reads the stored value there (a reset is
believed only when spelled as nothing). The compiler sources are those
of de284ff, which the gate ran on.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@cramforce

Copy link
Copy Markdown
Contributor Author

@ctate your three "Address review feedback" commits (d18b7a4, ffaa050, 538731b) are merged here with their tests kept. I replaced the rule they patch (and the one I had pushed for the first finding) with one pass, because each fix by syntax form left another form open: on ffaa050, tests/corpus/9646-copy-reset-paths.mjs prints undefined where Node prints the stored value for a reset behind a labeled break, for a destructuring store after a reset, and for c = keep ? c : undefined. The pass believes a reset only as a statement on every path to the read, storing a value spelled undefined/null/void e; everything else reads the stored value. Details in the description ("Review") and in the four thread replies.

Red checks to expect that are main's, not this change: executable-cache.test.ts "every build and run option is in the cache keys" (fix in #3306) and the five island-value-matrix cells (re-pin in #3298).

cramforce and others added 2 commits November 7, 2026 06:55
`for (...) { var c; use(c); c = v; }`: `var c;` stores nothing when it
runs, so on a later iteration the copy holds what the earlier one stored;
the pass took the declaration for a fresh `undefined` and an optional
argument dropped the value. A `var` holds `undefined` from the
function's entry and its bare declaration passes the fact through; a
`let` without a value is a fresh `undefined` each time it runs. The
`undefined` a reset is spelled with must be the global, not a binding of
the program's.

Co-Authored-By: Claude Code <noreply@anthropic.com>
c48fee9 makes the pass's write list leave a `var` declaration without a
value out; this branch had the same finding answered by passing the fact
through that declaration. Both are kept as one: the declaration is not a
write, and a `var` holds `undefined` from the function's entry. Its test
is kept beside this branch's.

Co-Authored-By: Claude Code <noreply@anthropic.com>
main's #3254 replaced narrowing-stability.ts's local skipOuter with
withoutParenthesesTypeWrappers from frontend/outer-expressions.ts and
forbids peel-only helpers elsewhere. The three uses this branch added
take the shared function, and its own parenthesis loop becomes
withoutParentheses.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@cramforce

Copy link
Copy Markdown
Contributor Author

Head 81f093e takes main b3ff410 (the shared outer-expression helpers of #3254 replace the local skipOuter this branch used; the build failure on the previous head was that). The fifth bot thread (a bare var declaration in a loop is no write) is answered in 8763249, with c48fee9 merged.

Red that is main's on this head: "Node island conformance 1/4" fails on packages/semver > island:semver ("runs semver in an island by default now: move its island:semver entry to default"); it fails the same way with main's compiler sources, so it is the conformance list, not this change.

@ctate
ctate merged commit 5f9cdbe into main Nov 7, 2026
78 of 80 checks passed
@ctate
ctate deleted the fix/js-ctor-arg-undefined branch November 7, 2026 18:17
cramforce added a commit that referenced this pull request Nov 7, 2026
Main gained #3169, #3310, #3282, #3315, #3327 and the untyped-promise
adoption. Two conflicts:
- frontend/lowering/expressions/binary.ts: #3169 adds the deferred-field read
  at the top of ensureString, whose signature #3290 extends by a conversion
  hint; both kept, and the new recursive call passes the hint on.
- ir/codec.generated.ts (generated): regenerated from the merged ir.ts with
  scripts/generate-ir-codec.mjs.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 7, 2026
…3096, #3104, #3257) into train/pi-1

Conflict: lower-stmts.ts, lowerVarDecl. #3274 moved the slot of an uninitialized let into uninitializedLetSlot; main's #3169 widens a binding a nested function can read before its first store, inline at the declaration. The helper stays and takes the widening as an opt-in argument that only the declaration passes, which also notes the early-read binding (the integration tree's resolution).

Co-Authored-By: Claude Code <noreply@anthropic.com>
espetro pushed a commit to espetro/scriptc that referenced this pull request Nov 8, 2026
Main gained vercel-labs#3315 and vercel-labs#3282 (both members of this train, merged by
themselves; they add no difference here any more), vercel-labs#3310 and vercel-labs#3169. No
conflict.

Co-Authored-By: Claude Code <noreply@anthropic.com>
yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 9, 2026
Fix four small differences from Node in the lowering: <T>process.env, satisfies, undefined results, class-field function names

Conflicts, resolved as the branch's owner wrote down for the train
(trains/lang-1/INBOX/small-silent-fixes.md):

- Five files against vercel-labs#3254, which replaced the local peel helpers this
  branch widened: main's side, with the call sites taking
  withoutParenthesesTypeWrappers (fork-target.ts, builtins/environment.ts,
  iterable-spelling.ts, program.ts with its two satisfies lines;
  builtins/arguments.ts is main's as it is).
- The former stripTypeCasts callers, which main spells
  withoutParenthesesAssertionsNonNull, take the same name in ten files, and
  three calls in lowerer.ts.
- lower-functions.ts against vercel-labs#3310 (on main): its refusal of a dropped
  stale return stays first, then this branch's check of an untyped body
  value.
- executable-cache.test.ts merged without a conflict and then named
  emit-lowered, from-lowered and from-lowered-accept twice (TS1117): the
  branch's three entries are dropped, the file is main's.

Co-Authored-By: Claude Code <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 81f093e5 Deployed Nov 7, 2026 by vercel[bot]
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.

2 participants