Repository navigation
Conversation
TypeScript keeps the narrowing of a property reference (and of a binding that a closure assigns) across calls. After `c.cb = fn` or `if (c.name !== undefined)`, a callback, method or getter that clears or replaces the property leaves later reads typed by the old value. Lowering trusted that type: maybeNarrow extracted the union payload unchecked, and `?.`, truthiness and `typeof` folded by the narrowed type. A second `c.cb?.()` after a self-clearing callback threw where Node answers undefined, and `string | undefined`, `string | number` or record fields read after such a call crashed or reinterpreted memory. NarrowingStability decides whether a narrowing is provably current: every property in the reference is construction-only (no store to its name outside `this.name = ...` in a constructor body, no accessor, no computed or reflective store into instances) with a stable root, or nothing between the earliest occurrence of the reference in the use's function and the use can run other code or store the property through another reference (standard library calls without callbacks are inert; a loop around the use contributes its whole body). Proven reads keep the unchecked extraction. Otherwise the read observes the stored value. In `?.` receivers, absence, type and truthiness tests, member reads the declared type supports, and slots that accept the declared type, the reference reads as declared, so the consumer tests at runtime exactly as Node does. Everywhere else the extraction is checked and throws Node's TypeError for the consuming operation (`Cannot read properties of undefined (reading 'x')`, `o.f is not a function`). Calls through callback fields use the same message. A null-only receiver of a restored `?.` short-circuits to undefined, and `typeof` evaluates an effectful union operand once. The checked failure path groups arms by message so wide unions stay small: the self-hosted compiler's IR grows by 0.03%. tsc-ts self-check, Linux, interleaved in one VM: check time -1.5% (noise), `--checkers 8` -1.2%. Corpus 4650 pins the shapes; a unit test pins which reads stay unchecked.
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
An assignment narrows a binding to the assigned arm, and TypeScript keeps that narrowing across a closure or method that stores another arm. After `if (acc !== null) return acc;` the checker still types the read as the old arm, the return slot rejects the declared `T | null`, and the checked extraction threw on the value the closure stored, where Node returns it. The compiler itself has this shape (lowerDynObjectLiteral), so the native compiler aborted with "a value of another type is not representable in the target union" and closure-nullable-union-return.ts diverged. A slot that accepts the declared type minus null/undefined now reads the stale reference as NonNullable<declared>, like a binding narrowed only by the absence test. Elements and properties of array and object literals are such slots when the literal itself sits in a typed slot (annotated declaration, argument, assignment, return of a function with a declared return type). Untyped literals keep the checked extraction.
A stale narrowing read in a call argument whose parameter accepts the declared type read as declared, so the callee's lowering tests the stored value. For standard-library callees that lowering is often specialized to the argument types programs pass: after `combo.p = {...}; combo()` the argument of `JSON.stringify(combo.p)` became `T | undefined` (SC1090), and after `ci(slow)` the handle of `timers.clearInterval(fast)` became `number | null` (SC2020), so 2072-expando-function-members.ts and 1804-timers-module.ts stopped compiling.
Call and construction arguments (directly or inside literal arguments) now read as declared only where the callee lowers any value of the parameter type: user callees, console methods, and standard-library parameters typed by a type parameter (`push(...items: T[])`, `Map.set`). Other standard-library arguments keep the checker's narrowing and the checked extraction.
# Conflicts: # packages/compiler/test/ts7/baselines/order-parity.json
# Conflicts: # packages/compiler/test/ts7/baselines/order-parity.json
# Conflicts: # packages/compiler/test/ts7/baselines/order-parity.json
# Conflicts: # packages/compiler/test/ts7/baselines/order-parity.json
This branch was successfully deployed
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.
Problem
TypeScript keeps the narrowing of a property reference (
c.cb,this.item,o.shape) across calls, and of aletthat a closure assigns. Afterc.cb = fnorif (c.name !== undefined), a callback, method or getter that clears or replaces the property leaves later reads typed by the old value. scriptc trusted that type:maybeNarrowbridged the union read with a tag-uncheckedunionNarrow;?., truthiness,typeofand equality folded by the narrowed type.So a second
c.cb?.()after a self-clearing callback threwundefined is not representable in the target unionwhere Node returnsundefined. Reads ofstring | undefined,Item | null,string | number, interface and discriminated-union fields after such a call segfaulted or reinterpreted memory (typeof c.vprintednumber 2.1e-314after the field became a string). The same held for a capturedletcleared by a closure.Fix
NarrowingStability(new,frontend/lowering/narrowing-stability.ts) decides whether a narrowing is provably current at the read:this, or a binding no other function assigns). Construction-only means no store to the name anywhere exceptthis.name = ...directly in a constructor body, no accessor of that name, and no computed or reflective store into class instances. This is the same conservative principle asbackend/cycle-mutability.ts, applied to source because lowering runs before that IR analysis exists; ornew, tagged templates,await/yield, user accessors, user iterators and user conversion hooks count; standard library calls without function arguments (Math,console, string, array andMap/Setmethods) don't. A loop around the use that starts inside that span contributes its whole body. TypeScript never carries a member narrowing into a nested function, and every narrowing is an occurrence of the reference, so this span covers every narrowing path.Proven reads keep the unchecked extraction. Otherwise the read observes the stored value:
?.receiver, an absence/type/truthiness test (??,||,&&,!,===,typeof,instanceof, conditions, template spans), a member read the declared type supports (a discriminant on every arm, or a method of the declared class), and a slot that accepts the declared type (arguments, returns, annotated initializers, assignments), the reference reads as declared, so the consumer tests at runtime exactly as Node does.Cannot read properties of undefined (reading 'x'),Cannot read properties of null (reading 'x'),o.f is not a function. Calls through callback fields use the same message.?.receiver whose only nullish arm isnullshort-circuits toundefined, andtypeofevaluates an effectful union operand (a getter) once.The checked failure path groups arms by message (Node's message for null/undefined, one shared stranded-value message for every other arm), so wide unions such as
IrExprdon't grow the IR. The first version emitted one throw per arm and pushed the self-hosted compiler's IR past V8's maximum string length.Remaining divergences (inherent to static representation)
const s = o.namewithout annotation,[o.name],f(o.name)withf(s: string)) throws the stranded-value TypeError where Node carriesundefinedon.c.v.toFixed()afterc.vbecame a string) throws scriptc's TypeError rather than Node'sc.v.toFixed is not a function.Evidence
4650-stale-property-narrowing.ts: optional calls through self-clearing callbacks (top level and in a function), direct calls after the clear,?./??/||/===/typeof/!/conditions/template/value reads after a clearing method,nullarms,string | numberreplaced by its other arm, interface fields cleared by a free function, a getter as the intervening call and a narrowed getter, callbacks run byforEach, a loop back edge, an aliased store without a call, a discriminated field replaced by another arm, aninstanceofsubclass replaced by its base, closure-assigned bindings as values and as member roots, and narrowings that stay valid (construction-only fields across a user call, library calls only). It fails on main at its first line and matches Node byte-for-byte with the fix.narrowing-stability.test.ts: construction-only fields across a user call, mutable fields with no call and with only library calls keep the unchecked extraction; user calls, method calls and loop back edges get the checked read with Node's message.origin/parallel-checke469e33), Linux, base and candidate built and run interleaved in one VM, diagnostics identical: single-threaded check time median 4.663 s → 4.642 s (−0.45% pooled over 27 runs each, two runs with opposite order: −1.5%, +0.6%);--checkers 8wall 1.280 s → 1.265 s. Binary +17.5 KB (+0.07%). The self-hosted compiler's IR grows 0.03%.