From 94036b088bbfa0997a75bfc30b06096ae059b6b1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:04:12 -0700 Subject: [PATCH] Narrow `.filter` predicate results to structural types, not just arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../lowering/containers/filter-predicates.ts | 57 ++++++++++++++++++- .../compiler/test/narrow-guard-traps.test.ts | 26 +++++++++ ...524-filter-predicate-subset-record.ts.json | 6 ++ .../8524-filter-predicate-subset-record.ts | 50 ++++++++++++++++ ...524-filter-predicate-subset-record.ts.json | 1 + 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8524-filter-predicate-subset-record.ts.json create mode 100644 tests/corpus/8524-filter-predicate-subset-record.ts create mode 100644 tests/harness/corpus-costs/8524-filter-predicate-subset-record.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts index 3934edbf02..e960bbf6bd 100644 --- a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts +++ b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts @@ -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; +} diff --git a/packages/compiler/test/narrow-guard-traps.test.ts b/packages/compiler/test/narrow-guard-traps.test.ts index be89ad3415..01508723eb 100644 --- a/packages/compiler/test/narrow-guard-traps.test.ts +++ b/packages/compiler/test/narrow-guard-traps.test.ts @@ -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"); + }, +); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8524-filter-predicate-subset-record.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8524-filter-predicate-subset-record.ts.json new file mode 100644 index 0000000000..98784a0d46 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8524-filter-predicate-subset-record.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8524-filter-predicate-subset-record.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8524-filter-predicate-subset-record.ts b/tests/corpus/8524-filter-predicate-subset-record.ts new file mode 100644 index 0000000000..01b6930710 --- /dev/null +++ b/tests/corpus/8524-filter-predicate-subset-record.ts @@ -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(","), +); diff --git a/tests/harness/corpus-costs/8524-filter-predicate-subset-record.ts.json b/tests/harness/corpus-costs/8524-filter-predicate-subset-record.ts.json new file mode 100644 index 0000000000..03a29dd70a --- /dev/null +++ b/tests/harness/corpus-costs/8524-filter-predicate-subset-record.ts.json @@ -0,0 +1 @@ +{ "plain": 1300, "san": 2800 }