Answer member presence probes off the value an assertion actually holds

Node erases a type assertion, so `typeof (stored as R).member` reads the
property off whichever value is stored: when `stored` is
`R | Record<K, R>` and the record arm is holding, the read answers
undefined and the probe is false. Two earlier decisions made this throw
instead. lowerAsExpression applied the checked union extraction for
`stored as R` eagerly, trapping on exactly the arm the probe exists to
detect; and lowerFieldRead answered the read from the ASSERTED checker
type, which also folded `typeof` to a constant.

Defer the assertion's extraction for a member PRESENCE PROBE - the
operand of `typeof`, or an operand of `===`/`!==`/`==`/`!=` against
undefined, null or `void 0` - when the stored union's record arms declare
the member unevenly. The read then answers per arm off the runtime tag
(assertedMemberRead), each declaring arm reading its slot and the rest
answering undefined; a read with no per-arm answer still applies the
extraction the assertion would have taken. `"member" in (stored as R)`
over the same shape likewise probes the stored value.

Every real use - the member called, compared by identity, read into a
typed slot - keeps the checked extraction, so a lying assertion still
throws at its use. Types don't lie; a probe simply isn't the use.

Corpus program asserted-arm-probe.ts covers typeof, undefined and
nullish probes on the optional and required shapes, `in` on the required
shape, a probe guarding the real call, and a real use that throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-27 15:22:07 -07:00
co-authored by Claude Opus 5.5
parent ec973e7221
commit 3a0bef8db7
8 changed files with 438 additions and 1 deletions
@@ -0,0 +1,202 @@
/* A record assertion used as the object of a member PRESENCE PROBE:
* `typeof (stored as R).member`, `(stored as R).member !== undefined`,
* `null != (stored as R).member`.
*
* Node erases a type assertion, so the read answers off the value actually
* stored: arms of the stored union that declare the member answer their
* slot, and the others answer undefined (native records are closed). An
* eager checked extraction of R at the assertion would instead trap on
* every other arm, turning the probe — code that exists precisely BECAUSE
* the stored value may be another arm — into a TypeError where Node
* answers "undefined".
*
* The assertion's extraction is therefore deferred to the member read.
* lowerAsExpression erases it for this use; property access answers the
* read per arm when `assertedMemberRead` can (arms that declare the member
* against arms that do not), and applies the extraction the assertion
* would otherwise have taken eagerly when it cannot
* (applyDeferredAssertion).
*
* Only a probe defers. A real use of the asserted value — the member
* called, compared by identity, or read in any other context — keeps the
* checked extraction, so a lying assertion still throws at the use
* (divergence 38's lying-assertion stance). Types don't lie; a probe
* simply isn't the use.
*/
import * as ts from "../../ts7/adapter.js";
import { type IrExpr, type IrType, type SrcLoc, isUnitType, typeEquals } from "../../../ir/ir.js";
import type { Lowerer } from "../lowerer.js";
import { tryLowerExpression } from "./try-lower-expression.js";
import { hostedStorageView } from "./hosted-narrowing.js";
/** The assertion node at the root of `node`, through parentheses; null
* when `node` is not an (`as` / `<T>`) assertion. */
export function assertedReceiverNode(
node: ts.Expression,
): ts.AsExpression | ts.TypeAssertion | null {
let e = node;
while (ts.isParenthesizedExpression(e)) e = e.expression;
return ts.isAsExpression(e) || ts.isTypeAssertion(e) ? e : null;
}
/** The property access this assertion is the receiver of, through
* parentheses: `(u as R).member`. Null for any other use, and for the
* optional chain `(u as R)?.member` (a unit arm answers undefined there
* instead of Node's read-of-undefined TypeError). */
function assertedReceiverAccess(
assertion: ts.AsExpression | ts.TypeAssertion,
): ts.PropertyAccessExpression | null {
let use: ts.Node = assertion;
for (;;) {
const parent = use.parent;
if (parent && ts.isParenthesizedExpression(parent) && parent.expression === use) {
use = parent;
continue;
}
break;
}
const access = use.parent;
return access &&
ts.isPropertyAccessExpression(access) &&
access.expression === use &&
access.questionDotToken === undefined
? access
: null;
}
/** An operand that names the absent values (`undefined`, `null`), through
* parentheses and the `void 0` spelling. */
function isAbsenceLiteral(node: ts.Expression): boolean {
let e = node;
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (e.kind === ts.SyntaxKind.NullKeyword) return true;
if (ts.isIdentifier(e)) return e.text === "undefined";
return ts.isVoidExpression(e) && ts.isNumericLiteral(e.expression) && e.expression.text === "0";
}
/** The property access this assertion is the receiver of when the read is
* a presence probe: the operand of `typeof`, or an operand of `===` /
* `!==` / `==` / `!=` against undefined or null. Null for the optional
* chain and for every real use (a call's callee, an identity comparison,
* a read that flows into a typed slot), which keep the assertion's checked
* extraction. */
export function assertedProbeAccessOf(
assertion: ts.AsExpression | ts.TypeAssertion,
): ts.PropertyAccessExpression | null {
const access = assertedReceiverAccess(assertion);
if (access === null) return null;
let use: ts.Node = access;
while (ts.isParenthesizedExpression(use.parent) && use.parent.expression === use)
use = use.parent;
const parent = use.parent;
if (parent === undefined) return null;
if (ts.isTypeOfExpression(parent) && parent.expression === use) return access;
if (ts.isBinaryExpression(parent)) {
const comparable = parent.left === use || (parent.right === use && parent.left !== use);
if (!comparable) return null;
const op = parent.operatorToken.kind;
const equality =
op === ts.SyntaxKind.EqualsEqualsEqualsToken ||
op === ts.SyntaxKind.EqualsEqualsToken ||
op === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
op === ts.SyntaxKind.ExclamationEqualsToken;
if (!equality) return null;
const other = parent.left === use ? parent.right : parent.left;
if (isAbsenceLiteral(other)) return access;
}
return null;
}
/** True when the union's record arms read `member` unevenly: at least one
* declares it and at least one does not. Those are exactly the arms
* assertedMemberRead answers per arm; anything else keeps the assertion's
* extraction (and with it the checked-narrow fence for a lying one). */
export function partialRecordMember(lowerer: Lowerer, union: IrType, member: string): boolean {
if (union.kind !== "union") return false;
const arms = lowerer.unions.get(union.unionId)?.arms ?? [];
let records = 0;
let declaring = 0;
for (const arm of arms) {
if (isUnitType(arm)) continue;
if (arm.kind !== "record") return false;
const shape = lowerer.shapes.get(arm.shapeId);
if (!shape || shape.tuple || shape.indexValue) return false;
records++;
if (shape.fields.some((field) => field.name === member)) declaring++;
}
return records > 0 && declaring > 0 && declaring < records;
}
/** The asserted record type whose checked extraction is deferred for the
* probe `access`, or null when the read keeps the ordinary assertion
* lowering. `value` is the assertion's lowered operand. */
export function deferredMemberReadTarget(
lowerer: Lowerer,
assertion: ts.AsExpression | ts.TypeAssertion,
access: ts.PropertyAccessExpression,
value: IrExpr,
): IrType | null {
if (assertedProbeAccessOf(assertion) !== access) return null;
if (value.type.kind !== "union") return null;
const target = lowerer.mapTypeOf(lowerer.checker.getTypeFromTypeNode(assertion.type));
if (target?.kind !== "record") return null;
if (!partialRecordMember(lowerer, value.type, access.name.text)) return null;
return target;
}
/** Whether the property access `expr` is a probe read through an assertion
* that the per-arm answer claims (the same shape deferredMemberReadTarget
* recognizes). Checker-derived field targets must not claim it first: the
* field target would apply the assertion's checked extraction and trap on
* the arm the probe exists to detect. */
export function deferredProbeReadAccess(
lowerer: Lowerer,
access: ts.PropertyAccessExpression,
): boolean {
const assertion = assertedReceiverNode(access.expression);
if (assertion === null) return false;
if (assertedProbeAccessOf(assertion) !== access) return false;
const value = tryLowerExpression(lowerer, access.expression);
return value !== null && deferredMemberReadTarget(lowerer, assertion, access, value) !== null;
}
/** The stored value of an `in` probe whose receiver is an erased assertion
* (`"stream" in (stored as R)`): Node asks membership of the value actually
* stored, which for a union of fixed record shapes the union path answers
* per arm. Null when the receiver is not such an assertion, or the stored
* value is not a union of fixed record shapes (those keep the ordinary
* lowering, and its refusals). */
export function assertedInOperand(lowerer: Lowerer, node: ts.Expression): IrExpr | null {
let rhs = node;
while (ts.isParenthesizedExpression(rhs)) rhs = rhs.expression;
if (!ts.isAsExpression(rhs) && !ts.isTypeAssertion(rhs)) return null;
const stored = tryLowerExpression(lowerer, rhs.expression);
if (stored === null || stored.type.kind !== "union") return null;
const arms = lowerer.unions.get(stored.type.unionId)?.arms ?? [];
const fixed = arms.every((arm) => {
if (arm.kind !== "record") return false;
const shape = lowerer.shapes.get(arm.shapeId);
return shape !== undefined && !shape.tuple && !shape.indexValue;
});
return arms.length > 0 && fixed ? stored : null;
}
/** Apply the extraction lowerAsExpression would have applied to the
* assertion's operand, for a member read with no per-arm answer. */
export function applyDeferredAssertion(
lowerer: Lowerer,
value: IrExpr,
target: IrType,
loc: SrcLoc,
): IrExpr {
if (value.type.kind === "union" && target.kind !== "union" && !typeEquals(target, value.type)) {
const helper = lowerer.narrowedArmHelper(value.type.unionId, target, loc);
if (helper !== null) return lowerer.checkedNarrowCall(helper, value, target, loc);
}
if (target.kind === "union" || target.kind === "record") {
const hosted = hostedStorageView(lowerer, value, target, loc);
if (hosted) return hosted;
}
lowerer.assertedValues.set(value, target);
return value;
}
@@ -56,6 +56,7 @@ import { assertsGlobalObject, extractionCopiesReference } from "../assertion-ide
import { iterableSlotValue, iterableUnionOf } from "../iterable-values.js";
import { isDeclBoundaryTypeNode } from "../../decl-projection.js";
import { isMessageListenerCall } from "./message-listeners.js";
import { assertedProbeAccessOf, deferredMemberReadTarget } from "./asserted-receiver.js";
/** `c ? a : b`. An explicit destination constructs each branch at that
* layout before joining it. Without one, retain the expression's inferred
@@ -1407,6 +1408,17 @@ export function lowerAsExpression(
if (inner.type.kind !== "dyn") {
const targetTs0 = lowerer.checker.getTypeFromTypeNode(expr.type);
const target = lowerer.mapTypeOf(targetTs0);
// A PRESENCE PROBE of a member read through the assertion (`typeof
// (u as R).member`, `(u as R).member !== undefined`): the read answers
// off the value actually stored, since Node erases the assertion and an
// arm that does not declare the member answers undefined. The read
// answers that per arm (assertedMemberRead); an eager extraction of R
// here would trap first and turn the probe into a TypeError. Defer to
// the read (asserted-receiver.ts); every real use keeps the extraction.
{
const access = assertedProbeAccessOf(expr);
if (access && deferredMemberReadTarget(lowerer, expr, access, inner) !== null) return inner;
}
if (target?.kind === "object") {
const asserted = checkedClassAssertion(lowerer, inner, target, locOf(expr));
if (asserted) return asserted;
@@ -125,6 +125,12 @@ import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../.
import { typedArrayLacksMember } from "../builtins/typed-array-members.js";
import { TEMPORAL_GLOBAL } from "../../builtin-modules.js";
import { tryLowerExpression } from "./try-lower-expression.js";
import {
applyDeferredAssertion,
assertedReceiverNode,
deferredMemberReadTarget,
deferredProbeReadAccess,
} from "./asserted-receiver.js";
import { lowerRecordInheritedMember } from "../record-inherited.js";
import { DATE_METHOD_HINT } from "../builtins/dates.js";
import {
@@ -1398,6 +1404,25 @@ export function lowerPropertyAccessExpr(
// receiver that lowers cleanly means the MEMBER is the gap — name
// the property and the receiver's type instead of "property access".
let recvLowered = lowerer.lowerExpr(expr.expression);
// A PRESENCE PROBE of a member read through an erased assertion
// (`typeof (u as R).member`, `(u as R).member !== undefined`): Node
// reads the property off the value actually stored, so an arm that does
// not declare it answers undefined. lowerAsExpression deferred the
// assertion's checked extraction for this read; answer it per arm
// (assertedMemberRead), and only a member with no per-arm answer applies
// the extraction the assertion would otherwise have taken (a lying
// assertion still throws on a real use, never on the probe).
{
const assertion = assertedReceiverNode(expr.expression);
const target = assertion
? deferredMemberReadTarget(lowerer, assertion, expr, recvLowered)
: null;
if (target !== null) {
const read = assertedMemberRead(lowerer, expr, recvLowered, loc);
if (read) return read;
recvLowered = applyDeferredAssertion(lowerer, recvLowered, target, loc);
}
}
// A receiver whose value union holds record types the checker's subtype
// reduction folded into its type (`{ status } | { status, value }` typed
// `{ status }`, a call of a function with an inferred return type): read
@@ -3145,6 +3170,12 @@ export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpressi
};
}
}
// A presence probe of a member read through a record assertion
// (`typeof (u as R).member`): the field target comes from the ASSERTED
// checker type and would apply the checked extraction, trapping on the
// very arm the probe exists to detect. Leave the read to the per-arm
// answer in lowerPropertyAccessExpr (asserted-receiver.ts).
if (deferredProbeReadAccess(lowerer, expr)) return null;
const target = lowerer.fieldTarget(expr);
if (target) return lowerer.fieldGetExpr(target, locOf(expr), expr);
if (expr.questionDotToken) return null;
@@ -50,6 +50,7 @@ import { type ClassInfo, storedClassValueType } from "../lower-classes.js";
import { classInstanceOf } from "../class-dynamic-dispatch.js";
import { InternalCompilerError } from "../../../errors.js";
import { tryLowerExpression } from "./try-lower-expression.js";
import { assertedInOperand } from "./asserted-receiver.js";
import { isJsSourceFile, locOf } from "../../program.js";
import { checkedClassInstanceOf } from "../class-construction.js";
import { unsupportedDiag } from "../../../diagnostics/diagnostic.js";
@@ -1672,7 +1673,11 @@ export function lowerInExpression(
if (probed) return probed;
}
}
let recv = lowerer.lowerExpr(expr.right);
// `"stream" in (stored as R)` probes the value actually stored (Node
// erases the assertion): a union of fixed record shapes answers per arm
// below, so the assertion's checked extraction must not run first (it
// would trap on the very arm the probe exists to detect).
let recv = assertedInOperand(lowerer, expr.right) ?? lowerer.lowerExpr(expr.right);
const classInfo =
recv.type.kind === "classval" ? lowerer.classes.get(recv.type.className) : undefined;
if (
@@ -0,0 +1,96 @@
// A feature-detection probe of a member read through a type assertion must
// answer off the value actually stored (Node erases `as`), never throw the
// checked-extraction TypeError. A real use of the same read keeps the
// checked conversion ("types don't lie": a lying assertion throws at the
// use, not at the probe).
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule, validateModule } from "../src/index.js";
const PRELUDE = [
"interface Streams { stream: () => string; fetch?: () => string }",
"interface Options<K extends string> { name: string; api: Streams | Record<K, Streams> }",
];
type IrModule = {
entry: string;
functions: { name: string; body: unknown }[];
};
async function compileToIr(
body: string[],
): Promise<{ ok: boolean; module: IrModule | null; diagnostics: string }> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-asserted-arm-probe-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ir.json");
await writeFile(entry, [...PRELUDE, ...body].join("\n"));
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!result.ok)
return {
ok: false,
module: null,
diagnostics: result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
};
const module = deserializeModule(await readFile(outPath, "utf8")) as unknown as IrModule;
expect(validateModule(module as never)).toEqual([]);
return { ok: true, module, diagnostics: "" };
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/** The IR bodies of the functions whose name starts with `root`, as text
* (a specialized function keeps its source name as a prefix). */
function bodies(module: IrModule, root: string): string {
return JSON.stringify(
module.functions.filter((fn) => fn.name.startsWith(root)).map((fn) => fn.body),
);
}
test("typeof probe of a member read through an assertion reads per arm", async () => {
const compiled = await compileToIr([
"function detect<K extends string>(input: Options<K>): boolean {",
' return typeof (input.api as Streams).stream === "function";',
"}",
'console.log(detect({ name: "one", api: { stream: () => "one" } }));',
]);
expect(compiled.ok, compiled.diagnostics).toBe(true);
// A checked extraction of the asserted arm would trap on the record arm;
// the probe answers from the stored union's runtime tag instead.
const detect = bodies(compiled.module!, "detect");
expect(detect).toContain('"unionIsTag"');
expect(detect).not.toContain('"nodeThrow"');
});
test("undefined and null presence probes read per arm", async () => {
for (const probe of [
"(input.api as Streams).stream !== undefined",
"(input.api as Streams).stream == null",
]) {
const compiled = await compileToIr([
"function detect<K extends string>(input: Options<K>): boolean {",
` return ${probe};`,
"}",
'console.log(detect({ name: "one", api: { stream: () => "one" } }));',
]);
expect(compiled.ok, compiled.diagnostics).toBe(true);
const detect = bodies(compiled.module!, "detect");
expect(detect).not.toContain('"nodeThrow"');
}
});
test("a real use of the asserted member keeps the checked extraction", async () => {
const compiled = await compileToIr([
"function call<K extends string>(input: Options<K>): string {",
" return (input.api as Streams).stream();",
"}",
'console.log(call({ name: "one", api: { stream: () => "one" } }));',
]);
expect(compiled.ok, compiled.diagnostics).toBe(true);
// The real use still applies the assertion's checked extraction: an arm
// that is not Streams throws at the read (the trap is the nodeThrow).
expect(bodies(compiled.module!, "call")).toMatch(/union\.narrow|nodeThrow/);
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/asserted-arm-probe.ts"
],
"diags": []
}
+84
View File
@@ -0,0 +1,84 @@
// Feature-detection probes of a member read through a type assertion.
//
// `input.api` is `Streams | Partial<Record<TApi, Streams>>` (optional) or
// `Streams | Record<K, Streams>` (required). Node erases `as`, so
// `(input.api as Streams).stream` reads the property off the value actually
// stored: when that is the record arm, the read answers undefined. The
// probe therefore answers "undefined" (false), where an eager checked
// extraction of Streams would throw on the record arm. A real use of the
// same read — calling the member when the wrong arm is stored — still
// throws, at the call.
type Api = "alpha" | "beta";
interface Streams {
stream: () => string;
fetch?: () => string;
}
interface Options<TApi extends Api> {
name: string;
api?: Streams | Partial<Record<TApi, Streams>>;
}
interface Required<K extends string> {
name: string;
api: Streams | Record<K, Streams>;
}
function detectTypeof<TApi extends Api>(input: Options<TApi>): boolean {
return typeof (input.api as Streams).stream === "function";
}
function detectUndefined<TApi extends Api>(input: Options<TApi>): boolean {
return (input.api as Streams).stream !== undefined;
}
function detectNullish<TApi extends Api>(input: Options<TApi>): boolean {
return (input.api as Streams).stream == null;
}
function detectRequired<K extends string>(input: Required<K>): boolean {
return typeof (input.api as Streams).stream === "function";
}
function requiredIn<K extends string>(input: Required<K>): boolean {
return "stream" in (input.api as Streams);
}
// The probe guards the real use: the call runs only on the arm that has
// the member, so the assertion never lies at the call.
function streamName<TApi extends Api>(input: Options<TApi>): string {
if (typeof (input.api as Streams).stream === "function") return (input.api as Streams).stream();
return "none";
}
// A real use that reaches the wrong arm throws, as it does in Node.
function callStream<TApi extends Api>(input: Options<TApi>): string {
try {
return (input.api as Streams).stream();
} catch (err) {
return err instanceof TypeError ? "TypeError" : "other";
}
}
const one: Streams = { stream: () => "one" };
const withFetch: Streams = { stream: () => "both", fetch: () => "fetch" };
const registry: Partial<Record<Api, Streams>> = { alpha: one, beta: withFetch };
const empty: Partial<Record<Api, Streams>> = {};
const required: Record<Api, Streams> = { alpha: one, beta: withFetch };
console.log(detectTypeof({ name: "one", api: one }));
console.log(detectTypeof({ name: "registry", api: registry }));
console.log(detectTypeof({ name: "empty", api: empty }));
console.log(detectUndefined({ name: "one", api: one }));
console.log(detectUndefined({ name: "registry", api: registry }));
console.log(detectNullish({ name: "one", api: one }));
console.log(detectNullish({ name: "registry", api: empty }));
console.log(streamName({ name: "one", api: one }));
console.log(streamName({ name: "registry", api: registry }));
console.log(detectRequired({ name: "one", api: one }));
console.log(detectRequired<"alpha" | "beta">({ name: "registry", api: required }));
console.log(requiredIn({ name: "one", api: one }));
console.log(requiredIn<"alpha" | "beta">({ name: "registry", api: required }));
console.log(callStream({ name: "one", api: withFetch }));
console.log(callStream({ name: "registry", api: registry }));
@@ -0,0 +1 @@
{ "plain": 5031, "san": 5482 }