From c294d129d057a433ad00a2f37055eb7a205f581d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:00:52 -0700 Subject: [PATCH] Filter a union of arrays arm by arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.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 --- .../lowering/containers/array-callbacks.ts | 100 +++++++++++++++--- .../corpus/8530-union-array-filter.ts.json | 6 ++ .../test/union-array-filter-emission.test.ts | 90 ++++++++++++++++ tests/corpus/8530-union-array-filter.ts | 86 +++++++++++++++ .../8530-union-array-filter.ts.json | 1 + 5 files changed, 270 insertions(+), 13 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-union-array-filter.ts.json create mode 100644 packages/compiler/test/union-array-filter-emission.test.ts create mode 100644 tests/corpus/8530-union-array-filter.ts create mode 100644 tests/harness/corpus-costs/8530-union-array-filter.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index d4183d5e5..ff4d9efaf 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -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, }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-union-array-filter.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-union-array-filter.ts.json new file mode 100644 index 000000000..3ebcb0e28 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-union-array-filter.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8530-union-array-filter.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/union-array-filter-emission.test.ts b/packages/compiler/test/union-array-filter-emission.test.ts new file mode 100644 index 000000000..0ff81a032 --- /dev/null +++ b/packages/compiler/test/union-array-filter-emission.test.ts @@ -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"); +}); diff --git a/tests/corpus/8530-union-array-filter.ts b/tests/corpus/8530-union-array-filter.ts new file mode 100644 index 000000000..0c1f74f93 --- /dev/null +++ b/tests/corpus/8530-union-array-filter.ts @@ -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); diff --git a/tests/harness/corpus-costs/8530-union-array-filter.ts.json b/tests/harness/corpus-costs/8530-union-array-filter.ts.json new file mode 100644 index 000000000..b563858c9 --- /dev/null +++ b/tests/harness/corpus-costs/8530-union-array-filter.ts.json @@ -0,0 +1 @@ +{ "plain": 1900, "san": 2200 }