Skip to content

Adapt method overrides across ABI differences, write Error codes, and close wrapper-reachable gaps - #1497

Merged
ctate merged 11 commits into
mainfrom
opt/override-variance-error-expando
Oct 20, 2026
Merged

ctate merged 11 commits into
mainfrom
opt/override-variance-error-expando

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Stacked on #1433. This PR lets method overrides differ from the method they override in parameter and result types, as long as each value converts. It also handles writes and presence tests of properties on Error values, and fixes several smaller gaps found in code behind generic wrappers.

Summary

  • Method override variance. An override no longer has to match the inherited signature exactly.
    • Which overrides qualify: each inherited argument must convert into the override's parameter: equal, wider (contravariant), or a checked extraction from an any slot. The override's result must convert into the inherited result: narrower, a union that converts, a Promise whose payload widens, or any convertible value over an any base. Examples: an async acquire(): Promise<void> over a base that returns any, over an untyped async JavaScript method, or over an async base. A toString(): string | undefined over Error's string.
    • How it lowers: the class keeps the inherited slot %C.m, so virtual dispatch and base-typed callers are unchanged. The slot converts the arguments in and the result out around the override's own body, %C.m$own. When nothing overrides below, TypeScript callers typed with the subclass call $own directly and see the declared result.
    • JavaScript overrides of builtin or TypeScript bases take the same path. One example is zod's ZodError.toString() { return this.message } over Error.prototype.toString. Their callers keep the slot, because a JavaScript result type is only inferred.
    • toString slots hand on what a ToString consumer would make of the result: a toString that returns undefined reads as "undefined" in String(err) and in templates, as in Node.
  • err.code = value on Error values typed with an intersection (Error & { code?: string }).
    • String values set the runtime error's code slot, which typed reads, 'code' in err and inspection use. If the error is already reflected as a checked-dynamic object, that object is updated too.
    • Other values clear the slot and keep the dynamic own property.
    • What changed for users: before, err.code = "E1" landed on a dynamic property while err.code read the empty slot, so the read was undefined.
  • 'key' in err on Error values, with any receiver: a value narrowed from unknown by instanceof Error, a call result, a caught value.
    • A key the static class declares is true.
    • Otherwise the answer combines two checks: a class test over the subclasses that declare the key, and a runtime lookup of the error's own properties. That covers properties set by Object.assign, by throw sites, or through dynamic writes.
    • stack stays fenced: Node has one, a compiled error has none.
  • Object.assign(err, ...sources) onto an Error target, the Object.assign(new Error(message), details) idiom. The sources' own properties land on the error, message, name and code feed back into the typed slots, and the call returns the error itself.
  • Error & { status: number; ... } intersections, the type of that idiom's result, are Error values. Members beyond Error's own and the system-error metadata read and write the error's own properties, and each read is checked at its declared type. The intersection is admitted only when every extra member has a type a checked read can produce.
  • Catch bindings:
    • catch (e: any) (and catch clauses compiled with useUnknownInCatchVariables off) reads the binding as its checked-dynamic value, like unknown. This covers member reads and writes (e.message = ...; throw e) and assertions (e as Error & { code?: string }).
    • A type guard with more than one statement over a catch binding (isFetchError(err), with an unknown parameter) is called with the caught value, instead of being refused.
    • Error members such as message on a binding narrowed to an interface that extends Error read through the same value.
  • Lambda parameters typed any only by their default ((n = lookup() ?? 5) => ...) are checked-dynamic slots, like any parameters of function declarations since Compile event-loop metrics, predicate-narrowed catch bindings and other wrapper-reachable gaps #1433.
  • Forwarded rest tuples with optional positions ((...args: Parameters<F>) => fn(...args) for such an F) forward their slots; an omitted argument arrives as undefined, which a default treats as omitted. If the wrapper reads args.length, the existing refusal stays.
  • value as never flowing into a typed parameter, from an any or unknown value, takes the checked extraction to the parameter's type, as value as T would. Before, the value was checked as a number.
  • Lexical this in a generic function instance no longer resolves to the receiver of whichever caller triggered the instantiation. An instance lowers as if alone. Before, a JavaScript constructor function compiled per call site (readable-stream's CorkedRequest, iconv-lite's stream constructors) hit the fence "the binding 'this' captured through a plain nested function" when first reached from code with its own receiver. That fence now points at the this keyword instead of the entry file's first line.

Forms not covered keep their refusals, or keep running as before:

  • Remaining override refusals: overrides whose results do not convert (for example a record over an unrelated record), and generator overrides.
  • 'code' in err after an explicit err.code = undefined answers false; Node answers true. Changing the code presence test would change existing programs.
  • In JavaScript files, err.code = value keeps the existing dynamic write. Routing it to the code slot changed how the runtime's Readable.from destroy path combines errors (5614-readable-from-iterable-errors), so that file kind keeps its current behavior.
  • zod's ZodError constructor now compiles but still contains two runtime fences, previously hidden behind the class-level refusal: new.target.prototype and Object.setPrototypeOf. Constructing a ZodError throws the fence.

Evidence

  • New corpus programs (Node vs native, byte-identical, plain and sanitized lanes):
    • 6400: override over an any base
    • 6401: parameter variance
    • 6402: overrides of an untyped JavaScript base
    • 6403: Error code writes
    • 6404: in, Object.assign and Error & {...} intersections
    • 6405: wrapped lambdas with any defaults, optional forwarded tuples, as never arguments
    • 6406: a package constructor function reading its receiver
    • 6407: JavaScript toString overrides of Error
    • 6408: overrides of an untyped async JavaScript base
    • 6409: catch bindings typed any, multi-statement guards, writes into caught errors
  • Zero-cost gate against Compile event-loop metrics, predicate-narrowed catch bindings and other wrapper-reachable gaps #1433 (scripts/zero-cost-gate.mjs, bench, the self-hosted compiler and all corpus entries, sharded over three sandboxes):
    • IR, LLVM and release executables are identical for every pre-existing program except three, each an intended change:
      • 5807-worker-error-values and 6390-catch-binding-predicate write err.code = "..." on Error & { code?: string }. The write now lands in the code slot (−8 B).
      • 6394-in-key-error-subclasses adds the runtime own-property lookup to 'key' in err (+0.21%).
    • The new corpus programs are refused by the baseline or are new.
    • Compile time: bench geomean −0.15% (95% CI −1.45% to +0.61%), self-hosted compiler −1.20%.
    • Earlier runs caught three issues, each fixed and re-checked:
      • A "code" literal in the always-linked scr_error.c shifted .rodata in every executable.
      • Functions placed in the middle of scr_json.c changed its emission order, which moved scr_dyn_assign_all. The new C functions now sit at the end of scr_json.c, which already has the literal.
      • JavaScript files writing err.code changed 5614-readable-from-iterable-errors.
  • Checks: pnpm manifest and pnpm node-compat (no change), pnpm node-compat:check, lint, format, pnpm test:ts7 --baselines-only (baselines added for 6400–6409), diagnostics, frontend and IR unit tests, and the self-hosted IR build all pass.

🤖 Generated with Claude Code

… close wrapper-reachable gaps

Overrides whose parameters widen and whose results convert into the
inherited ones keep the inherited slot, which converts around the
override's own body; Error values take code writes, `in` tests and
Object.assign; and a few gaps behind generic wrappers close:

- Method overrides: each inherited argument converts into the
  override's parameter (equal, wider, or a checked extraction from an
  `any` slot) and the override's result converts into the inherited one
  (narrower, a convertible union, a widening Promise payload, anything
  convertible over an `any` base). The slot %C.m keeps the inherited
  ABI and forwards to %C.m$own; TypeScript callers typed with the
  subclass call $own directly when nothing overrides below. Async
  overrides of sync and async bases, and JavaScript overrides of
  builtin or TypeScript bases (zod's ZodError.toString) are included;
  a toString slot converts its result with ToString.
- `err.code = value` on Error values in TypeScript: strings set the
  runtime code slot (and the error's checked-dynamic node, if any); other
  values clear it and keep the dynamic own property.
- `'key' in err` with any receiver: declared members answer true;
  otherwise a class test over the declaring subclasses, a runtime lookup
  of the error's own properties, or an Object.prototype member.
- `Object.assign(err, ...sources)` onto an Error target copies onto the
  error, re-reads name, message, stack, cause and code into the typed
  slots, and returns the error.
- `Error & { ... }` intersections whose extra members have checkable
  types are Error values; those members read and write the error's own
  properties, each read checked at its declared type.
- Catch bindings typed `any` read as checked-dynamic values (member
  reads and writes, assertions); a multi-statement type guard with an
  `unknown` parameter takes the caught value as an ordinary argument;
  Error members of a binding narrowed to an Error-extending interface
  read through the same value.
- Lambda parameters typed `any` by their default are checked-dynamic
  slots; forwarded-only rest tuples with optional positions forward
  their slots; `value as never` from an untyped value into a typed
  parameter takes the checked extraction to the parameter's type.
- Lexical `this` stops at generic instance frames, so an instance no
  longer sees the receiver of the caller that triggered it. The fence
  for `this` captured through a plain function now points at the
  `this` keyword.

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 5:07pm UTC

Comment thread packages/compiler/src/frontend/lowering/lower-exprs.ts Outdated
Comment thread packages/runtime/src/scr_json.c
Comment thread packages/runtime/src/scr_json.c
…piled prototype resets

- A function adapter whose source returns `any`/`unknown` (a generic
  wrapper's result handed back `as F`) converts the result through the
  checked-dynamic bridge when the slot's result is checkable: a record
  checks its shape, and a Promise keeps its identity and checks its
  payload when it settles. Before, these adapters threw "the call has
  no exact lowering" when called.
- In a class constructor, `Object.setPrototypeOf(this, new.target.prototype)`
  (directly, through a const, or the `if (Object.setPrototypeOf) ... else
  this.__proto__ = ...` form), and `Object.setPrototypeOf(this,
  C.prototype)` in C's own constructor when no class extends C, are
  no-ops: a compiled instance already has that prototype.
- Object.assign between records copies a source field into a wider
  target field (`{ bail: true }` into `bail?: boolean`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread tests/corpus/6410-wrapper-untyped-results.ts
Base automatically changed from opt/trace-reachable-blockers to opt/variadic-generic-wrappers October 20, 2026 07:16
cramforce and others added 2 commits October 20, 2026 00:29
…ance-error-expando

Brings main into the stack (thrown-value fixes, the lower-exprs split,
the checker helper removal). This branch's lower-exprs.ts hunks move to
expressions/relational.ts (`in` on errors), expressions/property-access.ts
(stdlib members of a structurally narrowed catch binding) and
expressions/narrowing.ts (assertions on an `any` catch binding); the
`this` blame, the `any` catch-binding read and the setPrototypeOf
feature test stay in lower-exprs.ts. The Error member read keeps both
this branch's intersection members and #1433's subclass-owned `code`.
Corpus 6407 and 6408 renumber to 6412 and 6413 (main has its own 6407
and 6408), and 6414 adds the thrown-value round trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Writes and deletes of `code` through an error's checked-dynamic node (a
`catch (e: any)` binding, a reflective write) changed only the node, so
typed `err.code` reads kept the old value. The slot sync that already
covered name, message, stack and cause now covers code: a string takes
the slot, any other value or a delete clears it. A write, delete or
Object.assign through an Error subclass's typed view now syncs the
slots the subclass inherits from Error after the class commit, which
writes only declared fields.

`'code' in err` read the code slot, so an own `code` set to undefined
answered false. It now asks the runtime: the slot holds a string, or the
error has an own `code` property on its node.
cramforce and others added 3 commits October 20, 2026 02:14
…expando

Brings in #1433's review follow-up (now merged into #1413). In `key in
err`, own `stack` and Object.prototype keys answer true, keys built-in
errors keep outside their class fields stay fenced, and every other key
keeps this branch's answer: a declaring subclass or a runtime-set own
property.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`throw Object.assign(failure, { bail: true })`, where the record's type
lacks the source's member, used to fall to the Object.assign fence: a
fixed record cannot grow a field. As the operand of a throw, the call now
copies the target into a checked-dynamic object and adds the source's
members. The copy is unobservable, because control leaves with the throw
and nothing on that path reads the target again. Paths that already
lowered run first, so no compiled program changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the review merge and "Keep an Error's code slot in step with
dynamic writes and own properties". With main's split, `'code' in err`
(now the runtime's error.hasCode) lands in expressions/relational.ts.
Corpus 6417-error-code-dynamic-writes renumbers to 6415 (main has its own
6417), and the corpus-cost entries of 6407/6408 follow their renumbering
to 6412/6413.

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

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings

  1. packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts:284-287 — The typed callback promisify wrapper drops the caller's this receiver, so a promisified unbound method rejects when invoked as a method of its original instance.

    Fix on Vercel

Base automatically changed from opt/variadic-generic-wrappers to main October 20, 2026 16:52
@ctate
ctate merged commit cfb9417 into main Oct 20, 2026
24 checks passed
@ctate
ctate deleted the opt/override-variance-error-expando branch October 20, 2026 17:10

This branch was successfully deployed

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