Read Number.prototype and Boolean.prototype as prototype objects

A JavaScript source reading Number.prototype got undefined: the checker
types it with the wrapper interface, the type mapper maps that interface to
f64, and nothing built a prototype object.

The read now lowers, in JavaScript sources only, to dyn.builtinPrototype,
served by a new gated unit that builds the two prototype objects the way
the language defines them: an ordinary object on Object.prototype carrying
the kind's methods as non-enumerable, writable, configurable data
properties, answered from one box per thread so repeated reads are the same
object. A method runs on the prototype's primitive (+0 / false), which is
its [[PrimitiveValue]] in Node.

Two follow-on gaps the read exposed are fixed with it: narrowing no longer
extracts a scalar from a prototype value, and a call through the prototype
no longer takes the dyn.toString/dyn.valueOf/primitive-method
specializations, which assume a primitive receiver.

The methods are not generic: a receiver that is neither the kind's
primitive nor the prototype object — the wrapper around +0 / false, which
is its [[PrimitiveValue]] — is Node's TypeError, so a copied
`Number.prototype.toString` called on a plain object throws instead of
answering 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:38:57 -07:00
co-authored by Claude Opus 5.5
parent 6cea7a348f
commit 9558fcddf3
6 changed files with 123 additions and 2 deletions
@@ -388,6 +388,15 @@ export function lowerTernary(
}
}
/** Whether `expr` is the checked-dynamic Number/Boolean prototype value a
* JavaScript source read (jsPrimitivePrototypeValue), or one of its
* members: the value is the prototype OBJECT, not the wrapper's primitive,
* so the checker's wrapper type must not extract a scalar from it. */
function isPrimitivePrototypeValue(expr: IrExpr): boolean {
if (expr.kind === "libCall") return expr.fn === "dyn.builtinPrototype";
return expr.kind === "dynKeyGet" && isPrimitivePrototypeValue(expr.value);
}
/** Checker-driven union narrowing. tsc's control-flow analysis narrows a
* union-typed reference at use sites (`if (r.kind === "ok") { ...r... }`
* types `r` as the ok-arm inside the branch); the IR value is still the
@@ -429,6 +438,10 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx
// instead of misreading the payload (the dyn boundary's usual stance).
// Object/array narrowings stay dyn-typed and keep their fences.
if (expr.type.kind === "dyn") {
// Number.prototype and Boolean.prototype (and their members) are the
// prototype OBJECTS, though @types/node declares them with the wrapper
// interface: extracting the wrapper's numeric value would throw.
if (isPrimitivePrototypeValue(expr)) return expr;
// A process.versions entry declared as a string can be absent here.
if (isOptionalProcessVersionsRead(lowerer, node))
return lowerer.coerceToExpected(expr, lowerer.withUndefinedArm(STRING));
@@ -1914,6 +1914,8 @@ export function lowerIntrinsicProperty(
lowerer.stdlibGlobalMember(expr.expression, "Object") === "prototype"
)
return builtinGlobalValue("Object", locOf(expr));
const primitivePrototype = jsPrimitivePrototypeValue(lowerer, expr);
if (primitivePrototype) return primitivePrototype;
const builtinPrototype = builtinPrototypeMethod(lowerer, expr);
if (builtinPrototype)
return {
@@ -5011,3 +5013,56 @@ function globalThisWrittenNames(lowerer: Lowerer, globalType: ts.Type): Set<stri
}
return names;
}
/** `Number.prototype` / `Boolean.prototype` in a JavaScript source: the
* prototype object the checked-dynamic world builds (with its methods), or
* one of its members read as a value. Typed sources keep their own
* lowering, so no typed program links the unit that builds it. */
function jsPrimitivePrototypeValue(
lowerer: Lowerer,
expr: ts.PropertyAccessExpression,
): IrExpr | null {
if (!isJsSourceFile(ts.getSourceFileOfNode(expr))) return null;
const loc = locOf(expr);
const value = (kind: string): IrExpr => ({
kind: "libCall",
fn: "dyn.builtinPrototype",
args: [{ kind: "strLit", value: kind, type: STRING, loc }],
type: DYN,
loc,
});
const receiver = expr.expression;
if (ts.isPropertyAccessExpression(receiver) && receiver.name.text === "prototype") {
const kind = primitivePrototypeKind(lowerer, receiver);
if (!kind) return null;
return {
kind: "dynKeyGet",
value: value(kind),
key: { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) },
type: DYN,
loc,
};
}
const kind = primitivePrototypeKind(lowerer, expr);
return kind ? value(kind) : null;
}
/** Whether `expr` names `Number.prototype` or `Boolean.prototype` in a
* JavaScript source, which lowers to the prototype object above rather than
* to the wrapper interface's numeric/boolean value. */
export function isPrimitivePrototypeRead(lowerer: Lowerer, expr: ts.Expression): boolean {
return primitivePrototypeKind(lowerer, expr) !== null;
}
/** The kind whose prototype `expr` names (`Number.prototype`) in a
* JavaScript source, if any. */
function primitivePrototypeKind(
lowerer: Lowerer,
expr: ts.Expression,
): "Number" | "Boolean" | null {
if (!ts.isPropertyAccessExpression(expr) || expr.name.text !== "prototype") return null;
if (!isJsSourceFile(ts.getSourceFileOfNode(expr))) return null;
if (lowerer.stdlibGlobalMember(expr, "Number") === "prototype") return "Number";
if (lowerer.stdlibGlobalMember(expr, "Boolean") === "prototype") return "Boolean";
return null;
}
@@ -135,6 +135,7 @@ import {
} from "./containers/indexed-objects.js";
import { lowerTupleReadMethodCall } from "./containers/array-methods.js";
import { tryFuseIndexRead } from "./array-values.js";
import { isPrimitivePrototypeRead } from "./expressions/property-access.js";
import {
lowerBufferConstruct,
lowerBytesPrototypeSetCall,
@@ -3954,6 +3955,12 @@ function lowerDynReceiverMethodCall(
)
recv = lowerer.coerceToExpected(recv, DYN);
if (recv.type.kind !== "dyn") return null;
// `Number.prototype.m(...)` reads the prototype OBJECT, whose `m` is its
// own member, so every primitive specialization below — which assumes a
// primitive receiver and coerces the value it is given — would be wrong.
// The shared dispatch reads the member and calls it with the prototype as
// `this`, which is Node's answer for the wrapper around +0 / false.
const prototypeReceiver = isPrimitivePrototypeRead(lowerer, access.expression);
if (
access.name.text === "normalize" &&
call.arguments.nodes.length <= 1 &&
@@ -4064,7 +4071,7 @@ function lowerDynReceiverMethodCall(
// prototype declares (Array carries includes/indexOf/slice too), so no
// other kind inherits them. Shared names would need a receiver-kind
// dispatch — they keep the fence.
if (DYN_STRING_ONLY_METHODS.has(access.name.text) && !ownCallable) {
if (DYN_STRING_ONLY_METHODS.has(access.name.text) && !ownCallable && !prototypeReceiver) {
// A checked-dynamic object can still carry its OWN member of that
// name: zod's schemas assign `trim`, `toLowerCase` and `startsWith`
// in their constructors (`z.string().trim().min(1)`), an object's
@@ -4094,6 +4101,7 @@ function lowerDynReceiverMethodCall(
// runtime dispatch.
if (
!ownCallable &&
!prototypeReceiver &&
!call.questionDotToken &&
!access.questionDotToken &&
isDynPrimitiveKindMethod(access.name.text) &&
@@ -4116,6 +4124,7 @@ function lowerDynReceiverMethodCall(
access.name.text === "toString" &&
call.arguments.nodes.length <= 1 &&
!ownCallable &&
!prototypeReceiver &&
!(checkerUntyped && jsAssignsOwnToString(lowerer, access.expression))
) {
const argument = call.arguments.nodes[0];
@@ -4134,7 +4143,7 @@ function lowerDynReceiverMethodCall(
// Every dyn kind's prototype chain declares valueOf, and the runtime
// answers each kind's own: primitives and plain objects themselves, a
// Date its time value, an own or inherited valueOf member its result.
if (access.name.text === "valueOf" && call.arguments.nodes.length === 0) {
if (access.name.text === "valueOf" && call.arguments.nodes.length === 0 && !prototypeReceiver) {
const webAssembly = lowerWebAssemblyValueOf(lowerer, access.expression, recv, locOf(call));
if (webAssembly !== null) return webAssembly;
return {
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/js-primitive-prototype-values.js"
],
"diags": []
}
@@ -0,0 +1,37 @@
// Number.prototype and Boolean.prototype are objects in JavaScript: the
// wrappers around +0 and false, carrying their methods as non-enumerable
// data properties. Reading one used to answer undefined.
console.log(typeof Number.prototype, typeof Boolean.prototype);
console.log(Number.prototype === Number.prototype, Boolean.prototype === Boolean.prototype);
console.log(Number.prototype.toString(), Number.prototype.toString(2), Number.prototype.toFixed(2));
console.log(Number.prototype.valueOf(), Boolean.prototype.valueOf(), Boolean.prototype.toString());
console.log(Object.getPrototypeOf(Number.prototype) === Object.prototype);
console.log(Object.getPrototypeOf(Boolean.prototype) === Object.prototype);
console.log(Number.prototype.hasOwnProperty('toString'), Number.prototype.hasOwnProperty('nope'));
console.log(Object.keys(Number.prototype).length, Object.keys(Boolean.prototype).length);
console.log(typeof Number.prototype.toString, Number.prototype.toString.name, Number.prototype.toString.length);
console.log(Boolean.prototype.valueOf.name, Boolean.prototype.toString.name, Boolean.prototype.toString.length);
// The methods are not generic: only a primitive of the kind, or the
// prototype object itself (the wrapper around +0 / false), is a `this`.
const attempt = (label, run) => {
try {
console.log(label, 'no-throw', JSON.stringify(run()));
} catch (error) {
console.log(label, error.constructor.name, error.message);
}
};
attempt('num-null', () => Number.prototype.toString.call(null));
attempt('num-object', () => Number.prototype.toString.call({}));
attempt('num-string', () => Number.prototype.valueOf.call('5'));
attempt('num-bool', () => Number.prototype.toFixed.call(true));
attempt('num-number', () => Number.prototype.toString.call(255, 16));
attempt('bool-object', () => Boolean.prototype.valueOf.call({}));
attempt('bool-number', () => Boolean.prototype.toString.call(1));
attempt('bool-boolean', () => Boolean.prototype.valueOf.call(true));
const holder = new Object();
holder.toString = Number.prototype.toString;
attempt('holder', () => holder.toString());
const other = new Object();
other.myValueOf = Number.prototype.valueOf;
attempt('holder-valueOf', () => other.myValueOf());
@@ -0,0 +1 @@
{ "plain": 3000, "san": 4600 }