Repository navigation
Continue a failed checked cast with its value in a dynamic tail - #1561
Merged
Merged
Conversation
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
`JSON.parse(text) as T`, `const x: T = anyValue` and `await res.json() as T` threw `TypeError: expected X at $.path, got Y` when the value did not fit `T`. Node never checks an assertion and continues with the value it has, so the build-container listener rejected an order Node accepts and a JWT with a string `exp` became a 500. A failed cast now keeps its value when it initializes a `const`/`let` binding declared in a function or block and only the code after the cast reads the binding. That code (the statements after the cast in each enclosing statement list, up to the declaration) lowers twice: the static copy, unchanged, runs while the value fits; a dynamic copy, in which the binding and everything derived from it is typed `any`, runs after a failure, so missing members read undefined, wrong-typed members keep their values, extra members stay visible in source order, and objects keep their identity. The cast sets the flag that selects the copy: the fused JSON.parse helper's slow path and the plain check call scr_dyn_check_recover, which takes back the TypeError and parks the value for checkedCast.fallbackTaken/fallbackTake. A failed value that flows into a typed slot outside the region is checked there, as any `any` value is: it continues when it fits and throws at that use when it does not. A statement of the dynamic copy with no `any` lowering becomes a runtime fence that throws only when a failed value reaches it. Module-level bindings, regions with hoisted declarations or super() calls, union targets and targets holding class instances, and --tier=static-only (and library mode) keep the throwing cast. `a?.b.c` on an `any` value now short-circuits only on `a`, so `.c` throws when `a.b` is undefined, as in Node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cramforce
force-pushed
the
opt/checked-cast-fallback
branch
from
October 20, 2026 22:25
5030fbd to
1ef31a1
Compare
Each armed site lowers the code after it twice, and a cast inside another site's tail is lowered in both copies, so n sequential casts produced 2^n copies of the code after the last one. A site now arms only while fewer than two splits enclose its tail or share its split point; later casts keep the throwing cast.
ctate
added a commit
that referenced
this pull request
Oct 21, 2026
euxaristia
pushed a commit
to euxaristia/scriptc
that referenced
this pull request
Oct 21, 2026
…il (vercel-labs#1561)" This reverts commit 6bb2711, reversing changes made to cd833e6.
euxaristia
pushed a commit
to euxaristia/scriptc
that referenced
this pull request
Oct 21, 2026
The test from vercel-labs#1548 casts JSON.parse to Partial<Envelope> into a block-local const, which continues as its parsed value since vercel-labs#1561, as in Node. The path messages are now pinned with the cast passed to a typed parameter, which is checked at the cast, and the block-local form is pinned against Node.
euxaristia
pushed a commit
to euxaristia/scriptc
that referenced
this pull request
Oct 21, 2026
`a?.b.c` on an `any` value answered undefined when `a` was present and `a.b` undefined: every later step of the chain read optionally. Node short-circuits only on the guarded receiver and throws reading `.c`. An `any` receiver now makes the tail an optional chain like a dyn one, and inside the chain's guarded body the consumed `?.` no longer marks the later dynamic reads optional. (Kept from vercel-labs#1561, whose checked-cast fallback is reverted.) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
JSON.parse(text) as T,const x: T = anyValueandawait res.json() as Tused to throwTypeError: expected X at $.path, got Ywhen the value did not fitT. Node never checks an assertion, so the build-container listener rejected an order Node accepts (request = JSON.parse(body) as Partial<BuildOrderListenerRequest>insidetry, with nested required members missing), and a JWT with"exp":"x"became a 500.A failed cast now keeps its value when the cast initializes a
const/letbinding in a function or block and only the code after the cast reads the binding. That code (the statements after the cast in each enclosing list, up to the binding's declaration) lowers twice:any, so the region lowers through theanypath. Missing members readundefined, wrong-typed members keep their values, extra members stay visible toObject.keys,JSON.stringify, spread andutil.inspectin source order, and objects keep their identity.The cast sets a flag that selects the copy. A failed value that flows into a typed slot outside the region (a typed parameter, field, outer binding or return) is checked there, as any
anyvalue is: it continues when it fits and throws the conversion's TypeError at that use when it does not. A statement of the dynamic copy that has noanylowering becomes a runtime fence that throws only when a failed value reaches it. Module-level bindings, regions with hoisted declarations, union targets and targets that hold class instances, and--tier=static-only(and library mode) keep the throwing cast.Design
Options considered for a value that does not fit
T:typeoffolding and dropped?.on non-optional members are already baked into the static code;anylowering; the region is lexical in this stage.Next stages: dynamic clones of TypeScript callees that receive a failed value (so
deliver(order)and module-level casts continue too), then guarded slots for values stored in fields, containers and typed promises.Changes
frontend/lowering/checked-fallback.ts(new): fallback sites (eligibility, tails), arming the check, theif (flag)split, the taint fixpoint and the dynamic lowering of a tail.lowerStmts: opens, arms and splits sites; dynamic tails fence statements like JavaScript files do.Lowerer.typeOfanswersanyfor tainted expressions; assertions on tainted values check nothing; literals that spread a tainted value, or hold one while initializing a binding or flowing into an untyped destination, build dynamically (a literal flowing into a typed slot keeps its layout and checks each member); property writes to tainted receivers write the dynamic object; the stdlib/npm member fences and the runtime-optional machinery skip tainted bindings.dynCheck.fallback: the fusedJSON.parsehelper gains a fallback variant; after the check,scr_dyn_check_recoverclears the TypeError and parks the value forcheckedCast.fallbackTaken/checkedCast.fallbackTake.a?.b.con ananyvalue now short-circuits only onaand throws whena.bis undefined, as in Node (it answeredundefined).--tier.Cost
--layouts=4 --runs=16): json-records −0.2%, intern-table −5.3%, http-api +2.0% (31,311 → 30,714 req/s), all inside their confidence intervals.--tier=static-onlykeeps the previous code.a?.b.conanyvalues now lowers through anoptChain(corpus/1544-dyn-json-reads.ts changes its IR and shrinks by 40 bytes).Build-container
With this change cherry-picked onto
int/bc(plus #1534 for the separate zod_parsebug that the listener reaches next), the listener accepts the fake environment's order:order -> 200 {"prewarmed":false}, where it answered400 invalid JSON bodybefore. It then stops atdeliverBuildOrder(buildOrder), which checks the incomplete order intoBuildOrder(TypeError: expected string at $.authToken, got undefined); Node continues into the build and fails there. Values that flow into typed functions and promises are the next stages (callee clones, then guarded slots).Tests
json-parse-fallback-{missing,wrong-types,extra-keys,nested,partial,flows}.ts, identical to Node in both lanes.tests/harness/dyncheck.test.ts: tests whose casts now continue expect Node's output; a new test pins the located checks where a failed value leaves its region and the module-level cast that still throws.packages/compiler/test/checked-cast-fallback.test.ts: the split IR,--tier=static-only, and ineligible sites.🤖 Generated with Claude Code