Throw Node's TypeError when an asserted callee holds no function

`f!()`, `t[k]!()` and `o.cb!()` on a value that holds undefined threw
scriptc's checked-extraction message ("undefined is not representable in
the target union …"). The non-null assertion is erased in JavaScript, so
Node throws the call's own "<callee> is not a function". The checked
extraction behind an asserted callee now carries that message, with
V8's spelling of the callee: parentheses and non-null assertions dropped
and call results printed as `f(...)`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-25 07:34:32 -07:00
co-authored by Claude Opus 5.5
parent cf399bc1bf
commit c1c4daed82
10 changed files with 113 additions and 13 deletions
@@ -12,7 +12,10 @@ export class CoercionState {
// Provenance for checked extraction; consumers must not infer it from names.
readonly checkedNarrows = new Set<string>();
// The extracted arm of each checked (non-defaulting) extraction helper.
readonly checkedNarrowArms = new Map<string, { unionId: string; tag: number; target: IrType }>();
readonly checkedNarrowArms = new Map<
string,
{ unionId: string; tag: number; target: IrType; message?: string }
>();
readonly retags = new Map<string, string>();
readonly narrows = new Map<string, string>();
}
@@ -394,18 +394,21 @@ export function unionRetagHelper(
* the catchable TypeError — divergence 38's lying-assertion stance (an
* unchecked unionNarrow would misread the payload where JS lets the
* impossible value flow on). Null when the target isn't a non-unit arm
* of the union — those uses keep their erasure/fences. */
* of the union — those uses keep their erasure/fences. `message` replaces
* the failure message (the TypeError of the operation consuming the
* value, such as a call's "f is not a function"). */
export function narrowedArmHelper(
lowerer: Lowerer,
fromId: string,
target: IrType,
loc: SrcLoc,
message?: string,
): string | null {
const from = lowerer.unions.get(fromId);
if (!from || isUnitType(target)) return null;
const tag = lowerer.armTag(fromId, target);
if (tag < 0) return null;
const key = `${fromId}:${tag}`;
const key = message === undefined ? `${fromId}:${tag}` : `${fromId}:${tag}:${message}`;
const existing = lowerer.coercions.narrows.get(key);
if (existing) {
lowerer.coercions.checkedNarrows.add(existing);
@@ -414,9 +417,22 @@ export function narrowedArmHelper(
const name = keyedHelperName(lowerer, "%union.narrow", key);
lowerer.coercions.narrows.set(key, name);
lowerer.coercions.checkedNarrows.add(name);
const fn = buildUnionNarrow(name, from, target, loc, (type) => lowerer.fmt(type));
const fn = buildUnionNarrow(
name,
from,
target,
loc,
(type) => lowerer.fmt(type),
null,
message ?? null,
);
if (!fn) throw new InternalCompilerError("lowerer bug: invalid checked union extraction");
lowerer.coercions.checkedNarrowArms.set(name, { unionId: fromId, tag, target: from.arms[tag]! });
lowerer.coercions.checkedNarrowArms.set(name, {
unionId: fromId,
tag,
target: from.arms[tag]!,
...(message === undefined ? {} : { message }),
});
lowerer.liftedFns.push(fn);
return name;
}
@@ -458,7 +474,8 @@ export function checkedNarrowCall(
let failure: IrExpr | null = null;
for (let tag = from.arms.length - 1; tag >= 0; tag--) {
if (tag === arm.tag) continue;
const message = unionNarrowFailureMessage(from.arms[tag]!, (t) => lowerer.fmt(t));
const message =
arm.message ?? unionNarrowFailureMessage(from.arms[tag]!, (t) => lowerer.fmt(t));
const thrown = nodeThrowExpr(1, "", message, type, loc);
failure =
failure === null
@@ -875,8 +875,15 @@ function checkedStaleArm(
/** V8's spelling of a callee in "is not a function": a string-literal
* element key reads as a property name (`o["a"]` is `o.a`), other keys
* keep their brackets. */
* keep their brackets, parentheses and non-null assertions disappear, and
* a call result reads `f(...)`. */
export function v8CalleeText(node: ts.Node): string {
// `!` is type-only syntax: the running JavaScript has no trace of it.
if (ts.isNonNullExpression(node) || ts.isParenthesizedExpression(node))
return v8CalleeText(node.expression);
// A call result prints with its arguments elided.
if (ts.isCallExpression(node) && !node.questionDotToken)
return `${v8CalleeText(node.expression)}(...)`;
if (ts.isElementAccessExpression(node)) {
const key = node.argumentExpression;
const object = v8CalleeText(node.expression);
@@ -89,7 +89,7 @@ import { classSymbolKeyOf } from "./symbol-fields.js";
import { lowerTypeofExpr } from "./expressions/typeof.js";
import { lowerIdentifierExpr } from "./expressions/identifiers.js";
import { lowerFieldCompoundValue, lowerIncDec } from "./expressions/updates.js";
import { lowerTernary } from "./expressions/narrowing.js";
import { lowerTernary, v8CalleeText } from "./expressions/narrowing.js";
import {
classFieldTarget,
lowerPropertyAccessExpr,
@@ -433,7 +433,12 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
const operand = assertionOperand(lowerer, expr, inner, target ?? null, loc);
if (operand) return operand;
if (target && target.kind !== "union" && !typeEquals(target, inner.type)) {
const helper = lowerer.narrowedArmHelper(inner.type.unionId, target, loc);
const helper = lowerer.narrowedArmHelper(
inner.type.unionId,
target,
loc,
assertedCalleeMessage(expr, target),
);
if (helper) {
return lowerer.checkedNarrowCall(helper, inner, target, loc);
}
@@ -1160,6 +1165,19 @@ const NUMERIC_COMPOUNDS = new Set<ts.SyntaxKind>([
ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken,
]);
/** The TypeError Node raises when an asserted callee (`t[k]!(…)`, `o.f!()`)
* holds no function: `!` is erased, so the call itself throws "<callee> is
* not a function" in V8's spelling of the callee. */
function assertedCalleeMessage(expr: ts.NonNullExpression, target: IrType): string | undefined {
if (target.kind !== "func") return undefined;
let child: ts.Expression = expr;
while (ts.isParenthesizedExpression(child.parent)) child = child.parent;
const call = child.parent;
if (!ts.isCallExpression(call) || call.expression !== child || call.questionDotToken)
return undefined;
return `${v8CalleeText(expr.expression)} is not a function`;
}
/** Whether the use of `t[i]!` tells undefined from NaN: a converting use
* that takes the value itself (see convertingUseOf), or a destination that
* holds a missing element (see assertedElementKeepsUndefined) other than a
@@ -10525,8 +10525,8 @@ export class Lowerer {
return unionRetagHelper(this, fromId, toId, loc, trappable);
}
narrowedArmHelper(fromId: string, target: IrType, loc: SrcLoc): string | null {
return narrowedArmHelper(this, fromId, target, loc);
narrowedArmHelper(fromId: string, target: IrType, loc: SrcLoc, message?: string): string | null {
return narrowedArmHelper(this, fromId, target, loc, message);
}
checkedNarrowCall(helper: string, value: IrExpr, type: IrType, loc: SrcLoc): IrExpr {
@@ -97,3 +97,22 @@ test("a deferred read requires a real undefined tag", () => {
const value: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc };
expect(buildUnionNarrow("bad", from, BOOL, loc, () => "", value)).toBeNull();
});
test("a failure message replaces every other tag's TypeError", () => {
const fn = buildUnionNarrow(
"call",
union,
STRING,
loc,
(type) => type.kind,
null,
"f is not a function",
)!;
const thrown = branches(fn).flatMap((branch) => {
const statement = branch.then[0]!;
return statement.kind === "throw" ? [statement.value] : [];
});
expect(thrown).toHaveLength(4);
for (const value of thrown)
expect(value).toMatchObject({ kind: "libCall", args: [{ value: "f is not a function" }] });
});
@@ -20,7 +20,10 @@ export function unionNarrowFailureMessage(
*
* Deferred scalar fields can supply the established undefined default:
* false for booleans or NaN for numbers. No other tag receives that default,
* and reference-bearing targets always use checked extraction. */
* and reference-bearing targets always use checked extraction.
*
* `failureMessage` replaces the TypeError message of every other tag (the
* error Node raises for the operation consuming the value). */
export function buildUnionNarrow(
name: string,
from: IrUnionDef,
@@ -28,6 +31,7 @@ export function buildUnionNarrow(
loc: SrcLoc,
formatType: (type: IrType) => string,
undefinedDefault: IrExpr | null = null,
failureMessage: string | null = null,
): IrFunction | null {
if (isUnitType(target) || target.kind === "void") return null;
const targetTag = from.arms.findIndex((arm) => typeEquals(arm, target));
@@ -66,7 +70,7 @@ export function buildUnionNarrow(
args: [
{
kind: "strLit",
value: unionNarrowFailureMessage(arm, formatType),
value: failureMessage ?? unionNarrowFailureMessage(arm, formatType),
type: STRING,
loc,
},
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8240-asserted-callee-errors.ts"
],
"diags": []
}
@@ -0,0 +1,25 @@
// A non-null assertion is erased in JavaScript, so calling an asserted callee
// that holds undefined throws the call's own TypeError, with V8's spelling of
// the callee: element keys, literal keys as names, parentheses and asserted
// links dropped, call results as f(...).
type F = (x: number) => number;
const table: Record<string, F> = { inc: (x) => x + 1 };
const obj: { cb?: F; inner?: { cb?: F } } = { inner: {} };
const arr: (F | undefined)[] = [undefined];
let maybe: F | undefined;
function get(): F | undefined { return undefined; }
const key = process.argv.length > 99 ? "inc" : "missing";
const cases: [string, () => number][] = [
["computed", () => table[key]!(1)],
["literal key", () => table["missing"]!(1)],
["dotted", () => obj.cb!(1)],
["nested", () => obj.inner!.cb!(1)],
["element", () => arr[0]!(1)],
["local", () => maybe!(1)],
["parens", () => (table[key])!(1)],
["call result", () => get()!(1)],
["parens outside", () => (table[key]!)(1)],
];
for (const [name, run] of cases) {
try { console.log(name, run()); } catch (e) { console.log(name, (e as Error).name + ": " + (e as Error).message); }
}
@@ -0,0 +1 @@
{ "plain": 3000, "san": 3600 }