Narrow .filter predicate results to structural types, not just arms

A written type predicate usually names FEWER members than the
receiver's element has — `(c): c is { text: string } => ...` over a
block that also carries a `textSignature` — and may name a structural
type that is no arm of the element union at all. Both shapes refused,
so `commitments.filter(pred)` over a union of records with differing
members did not compile.

Record targets now convert when their members are a SUBSET of the
source's: each target member is read and converted, the copy keeps the
source's keys in the source's order, and a member the source lacks
still has no slot to read and stays refused.

When no union arm matches the predicate's type, `structuralUnionConversion`
converts tag by tag: each arm the target can be read from returns its
converted value and an arm with no such read throws the same checked
TypeError a lying predicate throws, so the checked extraction still
traps rather than silently passing a foreign value through.

Corpus 8524 pins the subset-record and structural-union conversions;
`narrow-guard-traps.test.ts` pins the TypeError a predicate that
returns true for a foreign value raises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:38:12 -07:00
co-authored by Claude Opus 5.5
parent e198a9eec2
commit 94036b088b
5 changed files with 138 additions and 2 deletions
@@ -161,7 +161,13 @@ function conversionBody(
target.indexValue
)
return null;
if (source.fields.length !== target.fields.length) return null;
// A written predicate usually names FEWER members than the receiver's
// element has (`(c): c is { text: string } => ...` over a block with a
// `textSignature` and a `type`): the target's members are a subset of
// the source's, each read and converted, and the copy keeps the
// source's keys in the source's order. A member the source lacks has
// no slot to read, so it stays refused.
if (target.fields.length > source.fields.length) return null;
const fields: { name: string; value: IrExpr }[] = [];
for (const field of target.fields) {
const own = source.fields.find((f) => f.name === field.name);
@@ -214,7 +220,13 @@ function keepAbsence(
}
/** A union value to one of its arms, or to a union of some of its arms:
* each arm the target holds returns, any other arm throws. */
* each arm the target holds returns, any other arm throws.
*
* A written predicate may name a structural type that is no arm at all
* (`(c): c is { text: string; type: string } => ...` over the block union
* above, whose text arm also carries `textSignature`): when no arm matches
* exactly, each arm the target can be read from converts by its own shape,
* tag by tag, and an arm with no such read throws. */
function unionConversion(
lowerer: Lowerer,
input: IrExpr,
@@ -250,6 +262,8 @@ function unionConversion(
});
}
// Every arm the target names must be one of the union's own arms.
if (kept === 0 && to.kind !== "union")
return structuralUnionConversion(lowerer, input, unionId, to, typeText, loc);
if (kept === 0 || kept !== targetArms.length) return null;
body.push(
typeError(
@@ -259,3 +273,42 @@ function unionConversion(
);
return body;
}
/** A union value to a structural type that is no arm: each arm the target
* can be read from returns its converted value, any other arm throws. */
function structuralUnionConversion(
lowerer: Lowerer,
input: IrExpr,
unionId: string,
to: IrType,
typeText: string,
loc: SrcLoc,
): IrStmt[] | null {
const source = lowerer.unions.get(unionId);
if (!source) return null;
const body: IrStmt[] = [];
let kept = 0;
for (let tag = 0; tag < source.arms.length; tag++) {
const arm = source.arms[tag]!;
if (isUnitType(arm)) continue;
const payload: IrExpr = { kind: "unionNarrow", unionId, tag, value: input, type: arm, loc };
const value = converted(lowerer, payload, arm, to, loc);
if (value === null) continue;
kept++;
body.push({
kind: "if",
cond: { kind: "unionIsTag", unionId, tag, negated: false, value: input, type: BOOL, loc },
then: [{ kind: "return", value, loc }],
else_: null,
loc,
});
}
if (kept === 0) return null;
body.push(
typeError(
`a kept element is not a '${typeText}' (a type predicate returned true for a value of another type)`,
loc,
),
);
return body;
}
@@ -70,3 +70,29 @@ for (const v of inputs) console.log(width(v));
expect(result.stderr.toString()).toBe(UNION_TRAP);
},
);
test.skipIf(process.platform === "win32")(
"a lying filter predicate throws the checked-extraction TypeError",
async () => {
const result = await run(`
interface Cat {
kind: "cat";
meows: number;
}
interface Dog {
kind: "dog";
}
type Pet = Cat | Dog;
const pets: Pet[] = [{ kind: "cat", meows: 2 }, { kind: "dog" }];
const alwaysCat = (p: Pet): p is Cat => p !== undefined;
try {
console.log("kept", pets.filter(alwaysCat).length);
} catch (e) {
console.log("caught", (e as Error).name);
}
`);
expect(result.error).toBeUndefined();
// Node keeps the Dog and reports 2; the checked projection throws on it.
expect(result.stdout.toString()).toBe("caught TypeError\n");
},
);
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8524-filter-predicate-subset-record.ts"
],
"diags": []
}
@@ -0,0 +1,50 @@
// A written type predicate usually names FEWER members than the receiver's
// element (`(b): b is { type: "text"; text: string }` over a block that
// also carries `textSignature`): the predicate's record is no arm of the
// element union, so each kept element converts to it by its own arm, tag
// by tag. An arm the predicate's record cannot be read from throws the
// checked TypeError, because a predicate that returned true for it lied.
type Block =
| { type: "image"; data: string; mimeType: string }
| { type: "text"; text: string; textSignature?: string };
const blocks: Block[] = [
{ type: "text", text: "first" },
{ type: "image", data: "AAAA", mimeType: "image/png" },
{ type: "text", text: "second", textSignature: "sig" },
];
function texts(list: Block[]): { type: "text"; text: string }[] {
return list.filter((b): b is { type: "text"; text: string } => b.type === "text");
}
console.log("texts", texts(blocks).map((t) => `${t.type}:${t.text}`).join(","));
// The same narrowing through a guard function passed as a value.
function isText(b: Block): b is { type: "text"; text: string } {
return b.type === "text";
}
console.log("guarded", blocks.filter(isText).length);
// Every arm converts when the predicate names members all of them carry:
// each kept element is read field by field into the predicate's record.
interface A {
tag: "a";
n: number;
}
interface B {
tag: "b";
n: number;
}
function numbers(list: (A | B)[]): { tag: "a" | "b"; n: number }[] {
return list.filter((v): v is { tag: "a" | "b"; n: number } => v.n > 0);
}
console.log(
"numbers",
numbers([
{ tag: "a", n: 1 },
{ tag: "b", n: 0 },
{ tag: "a", n: 3 },
])
.map((v) => `${v.tag}${v.n}`)
.join(","),
);
@@ -0,0 +1 @@
{ "plain": 1300, "san": 2800 }