Skip to content

Continue a failed checked cast with its value in a dynamic tail - #1561

Merged
ctate merged 2 commits into
mainfrom
opt/checked-cast-fallback
Oct 20, 2026
Merged

ctate merged 2 commits into
mainfrom
opt/checked-cast-fallback

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

JSON.parse(text) as T, const x: T = anyValue and await res.json() as T used to throw TypeError: expected X at $.path, got Y when the value did not fit T. Node never checks an assertion, so the build-container listener rejected an order Node accepts (request = JSON.parse(body) as Partial<BuildOrderListenerRequest> inside try, 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/let binding 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:

  • the static copy, exactly as before, which runs while the value fits;
  • a dynamic copy, which runs after a failure: the binding reads the parsed value, and every expression derived from it is typed any, so the region lowers through the any path. Missing members read undefined, wrong-typed members keep their values, extra members stay visible to Object.keys, JSON.stringify, spread and util.inspect in 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 any value 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 no any lowering 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:

  • a guarded slot representation with a tag test on every read (the two-tier design's representation G): it costs every read of every slot the value could reach, measured at 13–29% on pointer-chasing record reads in a C microbenchmark, and the guard spreads to every expression derived from such a slot;
  • a partial record plus a side table: static readers of a failed member would still need a test, and typeof folding and dropped ?. on non-optional members are already baked into the static code;
  • lazy per-member validation: the binding would be dynamic even when the value fits, losing the schema parser's typed records;
  • dynamic copies of the consuming code (chosen): nothing changes per read; the code that runs while the value fits is the code that ran before; exactness comes from the any lowering; 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, the if (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.typeOf answers any for 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 fused JSON.parse helper gains a fallback variant; after the check, scr_dyn_check_recover clears the TypeError and parks the value for checkedCast.fallbackTaken/checkedCast.fallbackTake.
  • a?.b.c on an any value now short-circuits only on a and throws when a.b is undefined, as in Node (it answered undefined).
  • Docs: the runtime type checks section, the limitations page, and --tier.

Cost

  • Programs without an eligible cast compile byte-identically (IR, LLVM, executable): zero-cost gate on the bench and self sets and a corpus sample (shard 1/8 plus the first 487 programs): every differing program contains an eligible cast, except corpus/1544 (below). Compile time: bench geomean +0.52% (CI −0.15% .. +1.61%), self-hosted compiler +1.0%.
  • Programs with one run the same schema parse and the same static code while the value fits, plus one runtime call (a thread-local test), a flag store and one branch per tail: +14 instructions per cast in a parse-dominated microbenchmark (+0.70% instructions, cachegrind). The three runtime benchmark workloads that contain an eligible cast are neutral (--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.
  • Size: the dynamic copy of each tail, and for the first site the slice of the dynamic runtime it reaches (60–95 KB): json-records +1.3%, intern-table +2.3%, http-api +15.7%. --tier=static-only keeps the previous code.
  • a?.b.c on any values now lowers through an optChain (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 _parse bug that the listener reaches next), the listener accepts the fake environment's order: order -> 200 {"prewarmed":false}, where it answered 400 invalid JSON body before. It then stops at deliverBuildOrder(buildOrder), which checks the incomplete order into BuildOrder (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

  • Corpus: 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

@vercel

vercel Bot commented Oct 20, 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 Oct 20, 2026 11:11pm UTC

Comment thread packages/compiler/src/frontend/lowering/checked-fallback.ts
`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>
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
ctate merged commit 6bb2711 into main Oct 20, 2026
8 of 9 checks passed
@ctate
ctate deleted the opt/checked-cast-fallback branch October 20, 2026 23:15
euxaristia pushed a commit to euxaristia/scriptc that referenced this pull request Oct 21, 2026
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

1 active deployment
Preview — 8441f452 Deployed Oct 20, 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