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<Api> 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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-28 19:55:50 -07:00
co-authored by Claude Opus 5.5
parent d1abf69149
commit 9eaa210e1f
4 changed files with 84 additions and 3 deletions
@@ -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<Api>` 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)`,
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/filter-predicate-model-narrow.ts"
],
"diags": []
}
@@ -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<Api>` 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<A extends Api> {
kind: "chat";
api: A;
id: string;
window: number;
compat?: CompatMap[A];
}
interface Image {
kind: "image";
id: string;
}
type AnyModel = Model<Api> | 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 {};
@@ -0,0 +1 @@
{ "plain": 4307, "san": 81017 }