Skip to content

Treat optional members an index signature covers as keys, erase 'as never', and keep fromEntries objects asserted to records - #1496

Merged
ctate merged 4 commits into
mainfrom
opt/bc-index-records
Oct 20, 2026
Merged

ctate merged 4 commits into
mainfrom
opt/bc-index-records

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Three gaps involving index-signature records.

Optional members an index signature already covers. @types/node declares interface ProcessEnv extends Dict<string> { TZ?: string }. That made process.env and every spread of it (const env = { ...process.env }) a hybrid record: TZ got a struct slot, and every other key went to the overflow map. The same holds for a declared environment type such as { CI?: string; DEBUG?: string } & Dictionary<string>. Spreading these records into each other, or deleting their keys, was refused. The declared members also enumerated before the overflow keys, whereas Node keeps a single insertion order. On main, s["x"] = 1; s.best = 2 enumerated best, x without a warning. A record type now interns as the pure index-signature record when its declared members are all optional and typed as its slot, meaning the member type is the slot plus undefined. The slot then admits undefined, as the optional members do. Spread, delete, in with a runtime key, and every enumeration follow the object's insertion order. Required members, and members typed otherwise than the slot, keep the hybrid shape. The check reads the member type off the members, so a hybrid that does not qualify interns nothing new.

value as never. never maps to a numeric placeholder, so tag(value as never) checked an unknown value against number. The assertion now erases, and the consuming parameter applies the same validated extraction that any unknown value gets where the checker admits it. A typed value passes through unchanged and keeps its identity.

Object.fromEntries(...) as T for a declared record T, as in the redact<T extends Record<string, unknown>>(t: T): T idiom. The entries build a pure index-signature record, which shares no layout with T, so this used to fail with SC2002. In Node the assertion keeps the object, with the keys and order the entries gave it. fromEntries returns a fresh object nothing else references, so the checked-dynamic bridge is indistinguishable from the original. That bridge keeps keys, extra keys and order, as JSON.parse(text) as T already does. Sources that may be aliased keep the exact-shape fence.

Evidence

  • Corpus programs, byte-identical to Node in both the plain and SCRIPTC_SAN lanes. All three fail on main.
    • 6465-optional-members-over-index-signature.ts
    • 6466-never-assertion-argument.ts
    • 6467-fromentries-record-assertion.ts
  • Zero-cost gate against origin/main 4bbf31b (final run): 2500 identical, 2 differ. Both are corpus programs in exactly the shape class this change targets (optional members typed as the slot):
    • 2556-width-hybrid-shapes.ts (Opts/FewerOpts): executable shrinks by 0.13%.
    • 5233-inline-union-fields-shapes.ts (Scores): executable shrinks by 0.02%.
    • Both still match Node. Bench workloads and the self-hosted compiler are identical.
    • One cost: a read of such a member is now a keyed lookup and no longer a struct slot. That is the price of Node's single key order (see the Scores case above).
  • The self-hosted compiler IR build reports no diagnostics. pnpm lint and pnpm format:check are clean. pnpm test:ts7 --baselines-only passes, with the three new baselines recorded.
  • tests/harness/diagnostics.test.ts and type-mapper.test.ts pass.

🤖 Generated with Claude Code

…ever', and keep fromEntries objects asserted to records

A record type whose declared members are all optional and typed as its
index signature's slot (ProcessEnv's TZ?, '{ CI?: string } &
Dictionary<string>') interns as the pure index-signature record, its
slot admitting undefined: spread, delete and enumeration then follow one
insertion order. 'value as never' erases, so the consuming slot checks
an unknown value like any other. 'Object.fromEntries(...) as T' for a
declared record T goes through the checked-dynamic bridge, which keeps
the entries' keys and order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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 4:41pm UTC

Comment thread packages/compiler/src/frontend/lowering/expressions/narrowing.ts
@cramforce
cramforce requested a review from ctate October 20, 2026 06:08
…s or records

Object.fromEntries(...) as T passed the entries through the
checked-dynamic bridge, which copies native arrays and records: an array
shared by two entries, or held elsewhere, became separate copies, and
writes through the asserted object no longer reached it. The bridge now
applies only when the entries' values are scalars or checked-dynamic
values, which pass by value or by identity; other entries keep the
exact-shape fence (SC2002). Corpus 6467 checks that entries held as
unknown keep their identity; diagnostics fixture
fromentries-aliased-entries.ts pins the fence.
@ctate
ctate merged commit f4f2baf into main Oct 20, 2026
24 checks passed
@ctate
ctate deleted the opt/bc-index-records branch October 20, 2026 16:46

This branch was successfully deployed

1 active deployment
Preview — f7287e30 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