Filter a union of arrays arm by arm

`.filter` on `A[] | B[]` refused with SC2020: `lowerArrayUnionHofCall`
admitted only `map` and `forEach`. Under TS 5.2's callable-union rule the
predicate runs over `A | B`, and the result element is the one the
predicate names — `(b): b is TextContent` over a union of block arrays
answers `TextContent[]` — which no single arm's element need equal.

`filter` joins the same arm dispatch: each arm filters its own concrete
array through the existing per-arm helper, and its survivors then pass
through the checked conversion to the checker's result element, so a
predicate that kept an element it does not describe throws instead of
reinterpreting a value. An arm with no such conversion (a boolean
predicate that names no narrower element) leaves the SC2020 fence in place.

Typed programs are untouched: the gate only widens, and `map`/`forEach`
keep their exact helper key, so the helpers a program already emitted stay
byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 19:11:10 -07:00
co-authored by Claude Opus 5.5
parent b3d69baae8
commit c294d129d0
5 changed files with 270 additions and 13 deletions
@@ -632,7 +632,14 @@ function arrayHofHelper(
* original concrete array arm — never a copied `(T | U)[]` — and an
* arm-specific function adapter wraps the element and receiver arguments
* into the union-wide callback ABI. That preserves sparse-array reads,
* callback mutation, and the callback's third-argument identity. */
* callback mutation, and the callback's third-argument identity.
*
* `filter` joins them: the kept elements are the checker's result element
* (`(b): b is TextContent` over `(TextContent | ImageContent)[] |
* (ThinkingContent | TextContent)[]` answers `TextContent[]`), so each arm
* filters its own array and converts its survivors through the checked
* narrowing — a predicate that kept an element it does not describe throws
* the TypeError. An arm with no such conversion leaves the fence in place. */
export function lowerArrayUnionHofCall(
lowerer: Lowerer,
call: ts.CallExpression,
@@ -640,7 +647,7 @@ export function lowerArrayUnionHofCall(
receiverT: IrType & { kind: "union" },
): IrExpr | null {
const method = access.name.text;
if (method !== "map" && method !== "forEach") return null;
if (method !== "map" && method !== "forEach" && method !== "filter") return null;
if (!lowerer.isStdlibMember(access)) return null;
const def = lowerer.unions.get(receiverT.unionId);
if (!def || def.arms.length < 2 || !def.arms.every((arm) => arm.kind === "array")) return null;
@@ -682,8 +689,45 @@ export function lowerArrayUnionHofCall(
lowerer.badType(call, lowerer.typeOf(call));
}
if (method === "map") requireProducedArrayElement(lowerer, call, "'.map()'", fnRet);
const outElem = method === "map" ? callbackArrayElement(lowerer, call, fnRet) : VOID;
const resultT: IrType = method === "map" ? arrayOf(outElem) : VOID;
if (method === "filter" && !filterPredicateOk(lowerer, fnRet)) {
if (fnRet.kind === "void") {
lowerer.unsupported(
"SC1090",
call,
"'.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested)",
);
}
lowerer.badType(call, lowerer.typeOf(call));
}
// `map` stores the callback's answer; `filter` keeps the elements its
// predicate named. The kept element is the CHECKER's result element, which
// for a written type predicate is narrower than every arm's own:
// `(b): b is TextContent` over `(TextContent | ImageContent)[] |
// (ThinkingContent | TextContent)[]` answers `TextContent[]`. Each arm
// filters its own concrete array and converts its survivors to that
// element through the checked narrowing — a predicate that kept an element
// it does not describe throws instead of misreading it. No conversion the
// arms cannot make honestly means the fence stays.
let outElem: IrType = VOID;
const narrowing: (string | null)[] = [];
if (method === "map") {
outElem = callbackArrayElement(lowerer, call, fnRet);
} else if (method === "filter") {
const result = lowerer.mapTypeOf(lowerer.typeOf(call));
if (!result || result.kind !== "array") return null;
outElem = result.elem;
const typeText = filterTypeText(lowerer, call);
for (const array of arrays) {
if (typeEquals(array.elem, outElem)) {
narrowing.push(null);
continue;
}
const helper = checkedFilterNarrowing(lowerer, array.elem, outElem, typeText, locOf(call));
if (helper === null) return null;
narrowing.push(helper);
}
}
const resultT: IrType = method === "forEach" ? VOID : arrayOf(outElem);
for (const array of arrays) {
const armFnT = funcOf(
[arrayValueType(lowerer, array.elem), F64, array].slice(0, arity),
@@ -700,6 +744,7 @@ export function lowerArrayUnionHofCall(
arity,
fnRet,
outElem,
narrowing,
locOf(call),
);
return {
@@ -716,24 +761,31 @@ export function lowerArrayUnionHofCall(
function arrayUnionHofHelper(
lowerer: Lowerer,
method: "map" | "forEach",
method: "map" | "forEach" | "filter",
receiverT: IrType & { kind: "union" },
arrays: (IrType & { kind: "array" })[],
callbackT: IrType & { kind: "func" },
arity: number,
fnRet: IrType,
outElem: IrType,
narrowing: (string | null)[],
loc: SrcLoc,
): string {
const resultT: IrType = method === "map" ? arrayOf(outElem) : VOID;
const key = `union:${method}:${receiverT.unionId}:${typeKey(callbackT)}:${typeKey(resultT)}`;
const resultT: IrType = method === "forEach" ? VOID : arrayOf(outElem);
// A filter's per-arm conversions are part of what the helper does, so two
// sites that share receiver, callback and result but convert differently
// keep their own helper. `map`/`forEach` never narrow, and leaving their
// key untouched keeps the helpers a program already emitted byte-identical.
const narrowingKey =
method === "filter" ? `:${narrowing.map((helper) => helper ?? "").join(",")}` : "";
const key = `union:${method}:${receiverT.unionId}:${typeKey(callbackT)}:${typeKey(resultT)}${narrowingKey}`;
const existing = lowerer.arrHofHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, "%arr.union", key);
lowerer.arrHofHelpers.set(key, name);
const receiver = varRef("a.0", receiverT, loc);
const callback = varRef("f.0", callbackT, loc);
const branch = (array: IrType & { kind: "array" }): IrStmt[] => {
const branch = (array: IrType & { kind: "array" }, index: number): IrStmt[] => {
const tag = lowerer.armTag(receiverT.unionId, array);
const concrete: IrExpr = {
kind: "unionNarrow",
@@ -751,16 +803,38 @@ function arrayUnionHofHelper(
if (!typeEquals(adapted.type, armFnT)) {
throw new InternalCompilerError("lowerer bug: union-array callback stopped adapting");
}
const callee = arrayHofHelper(lowerer, method, array.elem, fnRet, arity, loc, outElem);
const invoke: IrExpr = { kind: "call", callee, args: [concrete, adapted], type: resultT, loc };
const callee = arrayHofHelper(
lowerer,
method,
array.elem,
fnRet,
arity,
loc,
method === "filter" ? array.elem : outElem,
);
const invoke: IrExpr = {
kind: "call",
callee,
args: [concrete, adapted],
type: method === "filter" ? arrayOf(array.elem) : resultT,
loc,
};
// Survivors of this arm's own array, converted to the checker's result
// element. The helper throws for a kept element the predicate does not
// describe, so a lying predicate never reinterprets a value.
const armNarrow = narrowing[index] ?? null;
const answer: IrExpr =
armNarrow === null
? invoke
: { kind: "call", callee: armNarrow, args: [invoke], type: resultT, loc };
return resultT.kind === "void"
? [
{ kind: "exprStmt", expr: invoke, loc },
{ kind: "return", value: null, loc },
]
: [{ kind: "return", value: invoke, loc }];
: [{ kind: "return", value: answer, loc }];
};
let body = branch(arrays[arrays.length - 1]!);
let body = branch(arrays[arrays.length - 1]!, arrays.length - 1);
for (let i = arrays.length - 2; i >= 0; i--) {
const array = arrays[i]!;
body = [
@@ -775,7 +849,7 @@ function arrayUnionHofHelper(
type: BOOL,
loc,
},
then: branch(array),
then: branch(array, i),
else_: body,
loc,
},
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8530-union-array-filter.ts"
],
"diags": []
}
@@ -0,0 +1,90 @@
// `.filter` on a union of arrays (`A[] | B[]`): the predicate runs over
// `A | B`, and the checker's result element is the arm the predicate names.
// The runtime value is one concrete arm's array, so the lowering must
// dispatch on that arm (unionIsTag), filter the arm's own array, and convert
// the survivors to the named element through the checked narrowing — never
// hand a union receiver to a static array intrinsic.
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 TextContent { type: 'text'; text: string }",
"interface ImageContent { type: 'image'; data: string }",
"interface ThinkingContent { type: 'thinking'; thinking: string }",
"type MessageContent =",
" | TextContent[]",
" | (ImageContent | TextContent)[]",
" | (ThinkingContent | TextContent)[];",
];
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-union-array-filter-"));
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 });
}
}
function named(module: IrModule, part: string): string {
return JSON.stringify(module.functions.filter((fn) => fn.name.includes(part)));
}
test("filter on a union of arrays dispatches on the arm", async () => {
const compiled = await compileToIr([
"function texts(content: MessageContent): string {",
" return content",
" .filter((block): block is TextContent => block.type === 'text')",
" .map((block) => block.text)",
" .join(' ');",
"}",
"const blocks: MessageContent = [{ type: 'text', text: 'a' }];",
"console.log(texts(blocks));",
]);
expect(compiled.diagnostics).toBe("");
expect(compiled.ok).toBe(true);
const module = compiled.module!;
// The tag dispatch that reads the runtime arm…
const union = named(module, "%arr.union");
expect(union).toContain("unionIsTag");
// …and the checked narrowing each arm's survivors pass through, so a
// predicate that kept an element it does not describe throws.
expect(named(module, "%arr.narrow")).toContain("%narrow.checked");
});
test("filter on a union of arrays keeps the fence without a narrowing", async () => {
// A predicate that names no narrower element answers the union-wide
// element; converting an arm's array to that element is a widening this
// lowering does not build, so the call stays a refusal instead of
// compiling to a different array layout.
const compiled = await compileToIr([
"function counts(content: MessageContent): number {",
" return content.filter((block): boolean => block.type !== 'image').length;",
"}",
"console.log(counts([]));",
]);
expect(compiled.ok).toBe(false);
expect(compiled.diagnostics).toContain("SC2020");
});
+86
View File
@@ -0,0 +1,86 @@
// `.filter` on a UNION OF ARRAYS (`A[] | B[]`, TS 5.2's callable-union
// rule): the predicate runs over `A | B`, and the checker's result element
// is the one the predicate names — not any arm's own element. The runtime
// value is one concrete arm's array, so the filter dispatches on that arm,
// keeps the arm's survivors in order, and converts each survivor to the
// named element.
interface TextContent {
type: "text";
text: string;
}
interface ImageContent {
type: "image";
data: string;
}
interface ThinkingContent {
type: "thinking";
thinking: string;
}
interface ToolCall {
type: "tool";
name: string;
}
type MessageContent =
| TextContent[]
| (ImageContent | TextContent)[]
| (ThinkingContent | ToolCall | TextContent)[];
function textOf(content: MessageContent): string {
return content
.filter((block): block is TextContent => block.type === "text")
.map((block) => block.text)
.join(" ");
}
const contents: MessageContent[] = [
[{ type: "text", text: "a" }, { type: "text", text: "b" }],
[
{ type: "image", data: "AA" },
{ type: "text", text: "c" },
],
[
{ type: "thinking", thinking: "t" },
{ type: "tool", name: "n" },
{ type: "text", text: "d" },
],
];
for (const content of contents) console.log("text", textOf(content));
// A predicate written as a function EXPRESSION rather than an arrow: the
// same receiver, the same per-arm dispatch.
function expressions(content: MessageContent): string {
return content
.filter(function (block): block is TextContent {
return block.type === "text";
})
.map((block) => block.text)
.join(" ");
}
console.log("expr", expressions(contents[1]!), expressions(contents[2]!));
// The predicate sees the union-wide element, so an arm-specific test is
// legal; the survivors still convert per arm.
function longTexts(content: MessageContent): string[] {
return content
.filter((block): block is TextContent => block.type === "text" && block.text !== "c")
.map((block) => block.text);
}
console.log("long", longTexts(contents[1]!).join("+"), longTexts(contents[2]!).join("+"));
// Node's `filter` reads the length once and visits elements in order; the
// arm dispatch must not change either, and the result is a fresh array.
let seen = 0;
function counted(content: MessageContent): number {
const kept = content.filter((block): block is TextContent => {
seen++;
return block.type === "text";
});
return kept.length;
}
console.log("counted", counted(contents[2]!), "seen", seen);
// The empty array is spelled with its arm's type: a bare `[]` literal
// assigned to this three-arm array union is a separate, unrelated gap (the
// literal's `number[]` element has no arm conversion), so it would obscure
// what this program pins.
const empty: MessageContent = [] as TextContent[];
console.log("empty", textOf(empty), counted(empty), "seen", seen);
@@ -0,0 +1 @@
{ "plain": 1900, "san": 2200 }