From 9eaa210e1fffce3f630e96039dff4b75b9e869fe Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:54:21 -0700 Subject: [PATCH] Narrow filtered union elements into a record with an arm's fields A written type predicate whose target is no arm of the element union but the same fields as one, with a nested union field narrowed (Model<"a"> from the Model arm), was refused. The kept elements of that arm now convert field by field, the nested union by its tag; any other kept element still throws the predicate TypeError. Co-Authored-By: Claude Opus 5.5 --- .../lowering/containers/filter-predicates.ts | 36 +++++++++++++-- .../filter-predicate-model-narrow.ts.json | 6 +++ tests/corpus/filter-predicate-model-narrow.ts | 44 +++++++++++++++++++ .../filter-predicate-model-narrow.ts.json | 1 + 4 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/filter-predicate-model-narrow.ts.json create mode 100644 tests/corpus/filter-predicate-model-narrow.ts create mode 100644 tests/harness/corpus-costs/filter-predicate-model-narrow.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 3934edbf0..18e6ac984 100644 --- a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts +++ b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts @@ -7,6 +7,8 @@ * * - a union element to one of its arms, or to a union of some of its * arms, by its tag; + * - a union element to a record with the same fields as one of its arms, + * through that arm; * - a checked-dynamic element by dynCheck; * - a class instance to a subclass by its class; * - a tuple or record to one with the same fields, field by field. @@ -229,10 +231,35 @@ function unionConversion( if (targetArms === null) return null; const body: IrStmt[] = []; let kept = 0; + let recordArmConversions = 0; for (let tag = 0; tag < source.arms.length; tag++) { const arm = source.arms[tag]!; const targetTag = targetArms.findIndex((t) => typeEquals(t, arm)); - if (targetTag < 0) continue; + if (targetTag < 0) { + // A record target that is no arm itself but the same fields as one + // (`Model<"a">` from the `Model` arm, whose nested union field + // the predicate narrows): that arm converts field by field. + if (to.kind !== "record" || arm.kind !== "record") continue; + const narrowedArm: IrExpr = { + kind: "unionNarrow", + unionId, + tag, + value: input, + type: arm, + loc, + }; + const converted = conversionBody(lowerer, narrowedArm, arm, to, typeText, loc); + if (converted === null) continue; + recordArmConversions++; + body.push({ + kind: "if", + cond: { kind: "unionIsTag", unionId, tag, negated: false, value: input, type: BOOL, loc }, + then: converted, + else_: null, + loc, + }); + continue; + } kept++; const payload: IrExpr = isUnitType(arm) ? { kind: "unitLit", unit: arm.kind === "undefinedT" ? "undefined" : "null", type: arm, loc } @@ -249,8 +276,11 @@ function unionConversion( loc, }); } - // Every arm the target names must be one of the union's own arms. - if (kept === 0 || kept !== targetArms.length) return null; + // Every arm the target names must be one of the union's own arms, or a + // record target converts from at least one arm. + if (recordArmConversions > 0) { + if (kept !== 0) return null; + } else if (kept === 0 || kept !== targetArms.length) return null; body.push( typeError( `a kept element is not a '${typeText}' (a type predicate returned true for a value of another type)`, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/filter-predicate-model-narrow.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/filter-predicate-model-narrow.ts.json new file mode 100644 index 000000000..c74a7d818 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/filter-predicate-model-narrow.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/filter-predicate-model-narrow.ts" + ], + "diags": [] +} diff --git a/tests/corpus/filter-predicate-model-narrow.ts b/tests/corpus/filter-predicate-model-narrow.ts new file mode 100644 index 000000000..4c97445d0 --- /dev/null +++ b/tests/corpus/filter-predicate-model-narrow.ts @@ -0,0 +1,44 @@ +// `.filter` with a written type predicate whose target is no arm of the +// element union but the same fields as one, with a nested union field the +// predicate narrows (`Model<"a">` from the `Model` arm). Kept elements +// convert field by field; a kept element of another shape would throw. + +interface CompatA { + strict?: boolean; +} +interface CompatB { + effort?: string; +} +interface CompatMap { + a: CompatA; + b: CompatB; +} +type Api = keyof CompatMap; +interface Model { + kind: "chat"; + api: A; + id: string; + window: number; + compat?: CompatMap[A]; +} +interface Image { + kind: "image"; + id: string; +} +type AnyModel = Model | Image; + +function restore(stored: AnyModel[]): Model<"a">[] { + return stored.filter((model): model is Model<"a"> => model.kind === "chat" && model.api === "a"); +} + +const stored: AnyModel[] = [ + { kind: "chat", api: "a", id: "1", window: 8, compat: { strict: true } }, + { kind: "image", id: "2" }, + { kind: "chat", api: "b", id: "3", window: 4, compat: { effort: "hi" } }, + { kind: "chat", api: "a", id: "4", window: 2 }, +]; +const restored = restore(stored); +for (const model of restored) console.log(model.id, model.api, model.window, model.compat?.strict, "compat" in model); +console.log(restored.length, restored.map((model) => model.window).reduce((a, b) => a + b, 0)); + +export {}; diff --git a/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json b/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json new file mode 100644 index 000000000..2440b7ddb --- /dev/null +++ b/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json @@ -0,0 +1 @@ +{ "plain": 4307, "san": 81017 }