Skip to content

Answer instanceof for every kind of untyped value - #3491

Merged
ctate merged 5 commits into
mainfrom
fix/instanceof-never-throws
Nov 9, 2026
Merged

ctate merged 5 commits into
mainfrom
fix/instanceof-never-throws

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

The faults

instanceof throws for a value that is not an instance. Node throws only when the right-hand side is not callable or has no object as its prototype; for every other question it answers.

class Shape {
  area(): number {
    return 1;
  }
}

function isShape(v: unknown): boolean {
  return v instanceof Shape;
}

console.log(isShape(new Shape()));
console.log(isShape(new Uint8Array(2)));
console.log(isShape(new URL('https://example.com/')));

Node prints true, false, false. Compiled by main (df9e6918f6) the program builds with no diagnostic, prints true and ends with Uncaught Error: Object.getPrototypeOf on this value is not supported yet [SC2020], exit status 1.

class Base {
  kind(): string {
    return 'base';
  }
}

class Box<T = unknown> extends Base {
  item: T | null = null;
}

function asBox(v: unknown): Box | null {
  const base = v instanceof Base ? v : null;
  if (base === null) {
    return null;
  }
  return base instanceof Box ? base : null;
}

function describe(v: unknown): string {
  if (v instanceof Error) {
    return 'error';
  }
  return asBox(v) === null ? 'other' : 'box';
}

console.log(describe(new Box()));

Node prints box. Compiled by main it ends with Uncaught TypeError: scriptc: Object.prototype reflection and mutation are not supported yet [SC1090], exit status 1: the throw is v instanceof Error.

The causes

value instanceof C, for a class C of the program that has a prototype (a class that is not generic), first tests the class keys of a capsule and then walks the value's prototype chain, for objects that got the class's prototype without being constructed by it. The walk's prototype read, scr_dyn_get_prototype_for_identity, answered for an object, an array, a promise and six native kinds, and passed every other value to Object.getPrototypeOf, which refuses a value whose prototype the runtime does not model.

value instanceof Error builds the view of a class instance to look for the error marker. The view gets the prototype the class's helper names, and for a class without prototype data of its own (a generic class below a class that has it) the helper answered Object.prototype, which no view can be given: attaching it is the refusal of Object.prototype reflection.

The fix

One rule each, where the question is answered.

  • In the runtime (packages/runtime/src/scr_dyn_class_test.c, a unit of its own): the read of a class test's walk, scr_dyn_class_test_prototype, has an answer for every value. An object, an array, a function, a record's or an array's capsule and an island object keep their chain. A Proxy has its target's prototype; a getPrototypeOf trap is refused by name, like the other traps the runtime does not run. Every other value ends the walk with null, so the test answers false: the value has only builtin prototypes on its chain (typed-array bytes, an ArrayBuffer, a DataView, a URL, an iterator, a stream, a generator object), or it is a class instance's capsule, which the class test answers by key before it walks. This is the rule the untyped instanceof (scr_bytes_instanceof_at) already follows: it walks objects, arrays and functions and answers false for the rest.
  • In the lowering (class-dynamic-dispatch.ts, the helper that names the prototype of an instance's view): a class without prototype data of its own below a class that has it gets a prototype made from its nearest ancestor's prototype data and named like the class, one object per class, created on first use. The view is built, inherits what the ancestors' prototypes hold, and util.inspect prints the class's name as Node does (GNode { form: null }); on main every operation that builds such a view threw.

The old read stays. scr_dyn_get_prototype_for_identity and the whole of scr_json.c are main's byte for byte; the new read decides which values have a chain a program class's prototype can be on and hands exactly those to the old one, so the two are the halves of one path, not two mechanisms. The walk now calls the libCall dyn.classTestPrototype (the renamed dyn.getPrototypeForIdentity), and the unit is selected by the runtime feature classTestPrototype, as scr_dyn_reflect_proto.c and scr_dyn_integrity.c are.

Two forms of this fix were measured and dropped, which is why it has this shape:

  • The new cases inside scr_json.c: that unit is linked by nearly every program, and the four new strings renumbered its later statics, so 4,057 executables changed their symbol names (and nothing else) for a read only class tests reach.
  • The old read deleted and the whole read in the new unit: the committed runtime profile (packages/runtime-pack-common/profiles) names the old function, so the committed runtime profile names only runtime functions failed, and refreshing the profile changes how every speed unit is optimized. With the old read left in place the profile is untouched.

instanceof still answers true exactly for an instance: no test that answered on main answers differently (the 920 cells of the first program below include every true cell of ten program classes). "Not an instance" is never a wrong false: a value the walk ends on cannot have a program class's prototype on its chain, because Object.setPrototypeOf refuses it by name (a Map, a Date, a typed array, a typed collection), a Proxy over a class instance is refused when it is made, and a class that extends a builtin either makes instances of its own (Date, Error, EventEmitter, the stream classes), which the key test answers, or is a build refusal. The values that do get a class's prototype at run time keep their chain, and the third program below asks them.

The family

Four corpus programs, differential against Node, one line per cell; a cell that throws prints the message.

ProgramCellsWhat variesmain differs from Nodethis branch
instanceof-untyped-every-kind92020 targets (Error, TypeError, Array, Map, Set, Promise, Date, RegExp, Uint8Array; program classes: plain, with a base, generic, below a generic class, abstract, implementing an interface, extending Error) asked of 46 values held as unknown: primitives, null, undefined, plain and null-prototype objects, an array, typed arrays, an ArrayBuffer, a DataView, a Map, a Set, a WeakMap, a WeakSet, a Date, a RegExp, errors, a promise, functions, a class value, generators, iterators, a URL, URLSearchParams, a TextEncoder, a Buffer, an instance of every program class120, all throws: 8 classes with a prototype × 13 native kinds; 7 of them × the 2 instances of generic classes below a base; Error × those 2 instances0
instanceof-untyped-forms236the value narrowed to object first, thrown and caught, the class taken from a variable, the value held as a union42, all throws (the same kinds in every form)0
instanceof-untyped-chosen-prototypes96objects created from an object, from a created object and from null, an object whose prototype was set, a Proxy of an object and of a created object; and eight values whose chain the program set to the prototype of a class, read off an instance (created from it, created from one created from it, an object and an array set to it, a Proxy of one created from it, a record, an array and a tuple held untyped and set to it), asked of the four program classes: 16 of those 32 cells are true under Node9, all throws (a Proxy asked for a program class)0
instanceof-untyped-streams42a Readable, a Writable, a PassThrough, an EventEmitter, an AbortController10, all throws0

In all: 1,294 cells; main differs from Node in 181, every one a throw, and this branch in 0. The cells were counted on main df9e6918f6; main b4e908ef00, the one this branch holds, still throws in all four programs.

The built-in targets and the generic program classes answer right on main for every value: a generic class has no prototype to walk to, so its test is the key test alone.

Not in this PR

Other faults the family found, all wrong answers and not throws, each with its own cause, each the same on main and on this branch; the corpus programs leave their cells out:

  • v instanceof K with K: typeof Base holding the subclass Leaf answers as v instanceof Base (true for a Base; Node false): the helper for a class taken from a variable uses the class value only for a function-local class.
  • instanceof Array answers by the kind of the value, not by its chain: Object.create([1, 2]) instanceof Array answers false (Node true), and an array set to another prototype still answers true (Node false).
  • a function whose prototype was set to a class's prototype answers false for the class (Node true).
  • Object.setPrototypeOf of a class instance held untyped returns without effect, and of a typed record, array, tuple or function it acts on a copy.

Build refusals the family met, unchanged: instanceof on a union of class instances and values without a class prototype test (a typed array or a Map beside a class), Object.create(Base.prototype) (class prototype reflection), a Proxy over a class instance or an array.

Cost

A program without a class test of an untyped value takes nothing from this change and is byte-identical. A program with one calls the new read where it called the old one and links the new unit: about 0.4 to 0.8 KB.

  • Executables: over the whole corpus 101 programs differ and 5,897 are identical (373 do not build on either side); no program differs by its runtime alone. The 101 are the programs whose compiled code has the walk (ir: changed %dyn.class.prototypeChainIdentity, exe: added scr_dyn_class_test_prototype): 93 existing corpus programs, the four this PR adds, the benchmarks throw-chains and dynamic-class-tests, the llvm-emitter fixture and the compiler itself. The existing ones grow by 424 to 848 bytes, +0.01 to +0.20 percent; the four new programs by +0.08 to +0.23 percent (compared with main compiling the same source).
  • Runtime pack (linux-x64-gnu, main b4e908ef00 and this branch, each built from its own sources): of the 1,563 files of main's pack 1,562 are byte-identical in the branch's pack and the one that differs is .source-hash, the recorded hash of the runtime sources; the pack gains scr_dyn_class_test.o in the release, dev and speed flavors (2.9 to 4.1 KB each). 001-hello.ts, 101-arithmetic.ts, 1000-json-stringify-basics.ts and 1020-async-basics.ts compile to the same bytes.
  • Speed: bench-runtime, 75 workloads: 75 neutral, 0 slower (geomean -0.1 percent); the two benchmarks with a class test's walk are among the neutral ones.
  • Compile time: benchmarks -0.90 percent, the compiler itself -0.09 percent, fixtures +0.38 percent, all within tolerance.

Proof

One full validation round of the pushed head (32f2b97554) against the main it holds (b4e908ef00), 52.3 minutes, verdict PASS:

  • typecheck: no TypeScript errors;
  • lanes: plain 26,244 passed and sanitized 22,580 passed, 0 failed (88 jobs, the sanitized bootstrap among them);
  • zero-cost gate over the whole corpus, with the 101 programs listed and a size cap of 0.3 percent on them: PASS, the numbers above; every listed program differs and nothing else does; the compiler compiles itself (135 bytes per source byte, no new dynamic-tier function);
  • bench-runtime: the numbers above;
  • Test262 (JSON, Reflect, Function, Proxy, Object): 0 regressed.

Also on the pushed head: unit tests 50 of 50 (json.test.ts, which links the new unit into the C test, dyn.test.ts, classes.test.ts and two more files), lint 0 warnings and 0 errors, format clean, pnpm test:ts7 --baselines-only complete, the four new programs pass in both lanes, the four programs equal Node cell by cell, and the committed runtime profile names only runtime functions passes with the profile untouched.

The pushed head holds main b4e908ef00. Main is 06ccf2a1e6 (#3489) at the time of the push; git merges the two with no conflict (five files changed on both sides, each by lines added in different places: the registration of a runtime unit and a declaration in scr_dyn.h), and the merged tree has no caller of the old libCall name left. The merge with 16ef6085c0 (#3485, the commit that touched those five files), uncommitted, was built and checked too: the same unit tests, lint, format, the four programs in both lanes, the runtime-profile test and the family all pass; the 92 files main changed after it include none of this change's 33. The lanes of the merge with the newest main are this PR's CI.

The 101 programs whose executable changes
bench/dynamic-class-tests
bench/throw-chains
corpus/3087-error-options-lifetime.ts
corpus/4083-dynamic-import-failure/main.ts
corpus/4455-live-view-reads-without-stores.ts
corpus/4635-contract-program-lifetime-layouts.ts
corpus/5113-tdz-let-const-class.ts
corpus/5680-array-growth-after-copy.ts
corpus/5681-array-pop-and-length.ts
corpus/5772-guarded-catch-narrows.ts
corpus/5914-dyn-in-class-prototype-members.ts
corpus/5933-class-fields-unknown-object.ts
corpus/5976-builtin-class-aliases.ts
corpus/6041-error-cause-redeclared.ts
corpus/6145-cjs-require-cycle/main.js
corpus/6237-error-subclass-delete-stack/main.js
corpus/6295-jose-hs256/main.mjs
corpus/6331-jose-root-entry/main.ts
corpus/6349-instanceof-subclass-capsules/main.ts
corpus/6411-prototype-reset-constructors/main.ts
corpus/6414-catch-values-roundtrip.ts
corpus/6478-any-annotated-dictionary.ts
corpus/6584-emitter-class-body-untyped-listeners.ts
corpus/6819-caught-instanceof-dynamic-payloads.ts
corpus/6911-structural-constructor-types.ts
corpus/6916-local-class-static-self-reference.mjs
corpus/6918-local-class-expression-self-reference.mjs
corpus/6924-instanceof-class-values-native.mjs
corpus/6925-instanceof-dynamic-constructors.ts
corpus/6937-class-value-arrays.mjs
corpus/6991-inspect-class-name-instanceof.ts
corpus/7050-date-subclass.mjs
corpus/7052-date-subclass-dynamic.mjs
corpus/7132-jose-pbes2/main.mjs
corpus/7481-class-runtime-heritage-object-error.js
corpus/7483-constructor-factory-traits.js
corpus/7537-inspect-custom-errors.cjs
corpus/7541-prototype-identity-stand-ins.ts
corpus/7621-class-methods-foreign-receivers.mjs
corpus/7734-caught-instanceof-class-binding.js
corpus/7796-boxed-subclass-identity.js
corpus/8078-instanceof-object-function.cjs
corpus/8260-class-prototype-views.mjs
corpus/8702-js-primitive-object-coercion.js
corpus/8803-error-subclass-union-identity.ts
corpus/8813-class-evaluation-identity.js
corpus/8826-instance-constructor.ts
corpus/8862-class-value-loop-instanceof.js
corpus/8868-class-static-has-instance-hook.js
corpus/8869-class-constructor-array.js
corpus/8870-instanceof-computed-operands.js
corpus/8879-constructor-statics.ts
corpus/8881-function-literal-slot-conversions.ts
corpus/8992-asserted-member-write-error.ts
corpus/9217-typed-rest-class-dyn-view.ts
corpus/9362-error-index-signature-in-catch.ts
corpus/9363-catch-binding-unknown-rules.ts
corpus/9410-constructor-returns-other-object.mjs
corpus/9431-declare-field-on-error-subclass.ts
corpus/9432-class-test-on-union-with-plain-arms.ts
corpus/9641-dyn-class-tables-many-classes.mjs
corpus/9656-promise-state-beside-the-promise.ts
corpus/9971-class-member-refusal-method.ts
corpus/caught-class-identity.ts
corpus/caught-standalone-class.ts
corpus/checked-class-factory.mjs
corpus/class-brand-probe-keys.ts
corpus/class-checked-upcast.ts
corpus/class-expando-intersection.ts
corpus/class-instance-constructor.js
corpus/class-interface-protocols.ts
corpus/class-prototype-descriptor-copy.mjs
corpus/class-union-hierarchies.ts
corpus/class-union-pointers.ts
corpus/contract-inline-array-dynamic-values.ts
corpus/dyn-typed-slot-whole-adoption.ts
corpus/dynamic-method-returns-receiver.mjs
corpus/error-js-accessor-name-message.js
corpus/generic-class-fields-unknown.ts
corpus/inferred-module-containers.mjs
corpus/instanceof-untyped-chosen-prototypes.ts
corpus/instanceof-untyped-every-kind.ts
corpus/instanceof-untyped-forms.ts
corpus/instanceof-untyped-streams.ts
corpus/js-class-member-dispatch.js
corpus/js-implicit-call-results.js
corpus/js-inferred-class-array-return.js
corpus/localized-failure-cycles.ts
corpus/localized-failure-held-values.ts
corpus/native-worker-exception-state.ts
corpus/native-worker-terminate-exception-state.ts
corpus/node24-no-error-assertions.ts
corpus/stackless-async-exceptions.ts
corpus/super-comma-jose-errors.js
corpus/union-class-beside-its-interface-replaceable.ts
corpus/union-class-beside-its-interface.ts
corpus/untyped-typed-array-index-read.ts
corpus/webassembly-wpt-jsapi.mjs
corpus/worker-class-instance-clone.ts
fixture/llvm-emitter
self/cli

🤖 Generated with Claude Code

cramforce and others added 5 commits November 9, 2026 03:48
`value instanceof C`, with C a class of the program that has a prototype, tests the class keys of a capsule and then walks the value's prototype chain. The walk's prototype read refused every value whose chain the runtime does not model: a typed array, an ArrayBuffer, a DataView, a Buffer, a URL, an iterator, a stream, a generator object, a Proxy, and an instance of a generic class below a class with a prototype. Node answers false for all of them; the compiled program threw `Object.getPrototypeOf on this value is not supported yet`.

`scr_dyn_get_prototype_for_identity`, the read of that walk, now has an answer for every value. An object, an array, a function, a record's or an array's capsule and an island object keep their chain; a Proxy has its target's (a `getPrototypeOf` trap is refused by name, like the other traps the runtime does not run); every other value ends the walk with null, because only builtin prototypes are on its chain, or because it is a class instance's capsule, which the class test answers by key before it walks. The untyped `instanceof` already follows this rule.

`value instanceof Error` builds the view of a class instance to look for the error marker. For a class without prototype data of its own below a class that has it (a generic class with a base), the helper that names the view's prototype answered Object.prototype, which no view can be given: building the view threw `Object.prototype reflection and mutation are not supported yet`, for a value that is no Error. Such a class now has a prototype made from its nearest ancestor's prototype data and named like the class, so the view is built, inherits what the ancestors' prototypes hold, and prints the class's name.

The runtime change is one function that only a program with such a class test links; the lowering change reaches only a program with such a class.

The family (four corpus programs, differential against Node): 20 targets asked of 46 kinds of value held as `unknown`, the value narrowed to `object`, thrown and caught, the class taken from a variable, objects with a chosen prototype, a Proxy, streams.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The read of a class test's walk lived in scr_json.c, which nearly every program links. Its new strings renumbered the unit's later statics, so 4,057 executables changed their symbol names for a read only class tests of untyped values reach.

The read is now scr_dyn_class_test_prototype in scr_dyn_class_test.c, a unit selected by the runtime feature classTestPrototype (the libCall dyn.classTestPrototype, which replaces dyn.getPrototypeForIdentity). scr_json.c is main's again without the old read. A program with such a test changes in its compiled code, by the name of the call; every other program keeps its bytes.

The chosen-prototypes program gains the cells where Node answers true: objects, arrays and capsules whose chain the program set to a class's prototype, and a Proxy of one.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
The full round failed one test in both lanes: the committed runtime profile names scr_dyn_get_prototype_for_identity, and the function was deleted. Deleting it would need a profile refresh, which changes how every speed unit is optimized.

scr_json.c is main's file again, byte for byte. The class test's read in scr_dyn_class_test.c decides which values have a chain a program class's prototype can be on and hands those to scr_dyn_get_prototype_for_identity, which reads it as before; every other value still ends the walk.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
@vercel

vercel Bot commented Nov 9, 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 Nov 9, 2026 4:20pm UTC

@ctate
ctate merged commit 4e0079e into main Nov 9, 2026
88 of 90 checks passed
@ctate
ctate deleted the fix/instanceof-never-throws branch November 9, 2026 16:39
@cramforce
cramforce requested a review from ctate November 9, 2026 17:17
@cramforce

Copy link
Copy Markdown
Contributor Author

CI on this PR's head (run 42653656935): 83 checks passed, every Linux job among them. Two macOS jobs are red (and the aggregate test with them), neither on a test this change reaches:

Main's run of this PR's merge commit 4e0079e605 (run 42657061899, still running) has one red job so far, test (Linux, san, 19/20): llvm differential corpus (dev, sanitized) > 8935-node-island-closed-const-primitive/main.ts printed libnode mapped: true where Node prints false. That program has no class and no instanceof either, and its release flavor passed in the same job. The same job passed on this PR's own run, on main's two commits before this one (ae8ab8ba08, 0151ad7f33) and on the commit after it (93d7e11f76, run 42659321022), which contains this change. So these runs say it is not this change but an unsteady result of that program's dev build; it is noted for a follow-up.

🤖 Generated with Claude Code

This branch was successfully deployed

1 active deployment
Preview — 32f2b975 Deployed Nov 9, 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