Narrow filter results through written type predicates with checked conversions

`.filter` with a written type predicate (`(p): p is Cat => ...`, or a
guard function passed as a value such as `filter(isDog)`) was refused with
SC1090, because TypeScript does not check the predicate. An inferred
predicate that keeps several union arms (`v => v !== undefined` over
`(string | number | undefined)[]`) was refused too.

The filter loop now keeps the receiver's element type, and the result
converts to the predicate's type element by element
(filter-predicates.ts):
- a union element to one of its arms, or to a union of some of its arms,
  by its tag;
- 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.

An element the predicate's type does not describe, kept because the
predicate returned true for it, throws a catchable TypeError: "a kept
element is not a 'Cat' (a type predicate returned true for a value of
another type)". The diagnostics fixture filter-narrow.ts, which pinned
the three refusals, compiles now; corpus 7007 covers its cases.

Fixes #1680.
This commit is contained in:
Chris Tate
2026-10-22 07:47:03 -05:00
parent 6e6267934d
commit dd3ea7c00c
9 changed files with 435 additions and 90 deletions
@@ -59,6 +59,7 @@ import {
} from "./array-iteration.js";
import { dynamicReceiverThrows } from "./dynamic-receivers.js";
import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js";
import { checkedFilterNarrowing } from "./filter-predicates.js";
/** `a.map(fn)` / `a.filter(fn)` / `a.forEach(fn)` desugar to a direct
* call of a synthetic module function — one per method + element/result
@@ -123,10 +124,11 @@ export function lowerArrayHofCall(
}
lowerer.badType(argNode, lowerer.typeOf(argNode));
}
const outElem =
const filtered =
method === "filter"
? filterResultElem(lowerer, call, argNode, elem, valueT, booleanFilter)
: callbackArrayElement(lowerer, call, fnRet);
: null;
const outElem = filtered !== null ? filtered.elem : callbackArrayElement(lowerer, call, fnRet);
// A filter that re-tags its elements keeps the single union-typed visit.
const fast =
present || booleanFilter || (method === "filter" && !typeEquals(outElem, elem))
@@ -145,19 +147,33 @@ export function lowerArrayHofCall(
present,
);
const resultType: IrType = method === "map" || method === "filter" ? arrayOf(outElem) : VOID;
return {
const result: IrExpr = {
kind: "call",
callee: helper,
args: [receiver, fnArg, ...(fast ? [fast] : [])],
type: resultType,
loc,
};
const narrowing = filtered?.narrowing ?? null;
if (narrowing === null) return result;
return {
kind: "call",
callee: narrowing.helper,
args: [result],
type: arrayOf(narrowing.elem),
loc,
};
}
/** The checker-selected element of a filter result, with the same trust
* boundary as the historical narrowing filter: an inferred inline type
* predicate may prove one source arm; a written predicate annotation may
* not. Boolean is safe when every discarded arm is a nullish unit. */
/** The element a filter keeps, and the checked conversion of the result
* to the checker's narrower element when the predicate does not prove it.
* An inferred inline type predicate proves one source arm, or a class
* view, so the loop keeps that type directly. A written predicate (`(p): p
* is Cat => ...`, a guard function passed as a value) is an assertion
* TypeScript does not check, and an inferred predicate that keeps several
* arms has no single arm to re-tag: the loop keeps the receiver's element
* and `narrowing` (checkedFilterNarrowing) converts each kept element.
* Boolean is safe when every discarded arm is a nullish unit. */
function filterResultElem(
lowerer: Lowerer,
call: ts.CallExpression,
@@ -165,7 +181,37 @@ function filterResultElem(
elem: IrType,
valueT: IrType,
booleanFilter: boolean,
): IrType {
): { elem: IrType; narrowing: { helper: string; elem: IrType } | null } {
const kept = filterKeptElem(lowerer, call, argNode, elem, valueT, booleanFilter);
if (typeof kept !== "string") return { elem: kept, narrowing: null };
return { elem, narrowing: { helper: kept, elem: filterCheckerElem(lowerer, call, elem) } };
}
/** The checker's spelling of a filter result's element type. */
function filterTypeText(lowerer: Lowerer, call: ts.CallExpression): string {
const result = lowerer.typeOf(call);
const element = lowerer.checker.isArrayType(result)
? lowerer.checker.getTypeArguments(result as ts.TypeReference)[0]
: undefined;
return element === undefined ? "" : lowerer.checker.typeToString(element);
}
/** The checker's element of a filter result, or the receiver's when the
* filter introduces no refinement. */
function filterCheckerElem(lowerer: Lowerer, call: ts.CallExpression, elem: IrType): IrType {
const result = lowerer.mapTypeOf(lowerer.typeOf(call));
return result?.kind === "array" ? result.elem : elem;
}
/** filterResultElem's element, or the name of the checked narrowing helper. */
function filterKeptElem(
lowerer: Lowerer,
call: ts.CallExpression,
argNode: ts.Expression,
elem: IrType,
valueT: IrType,
booleanFilter: boolean,
): IrType | string {
const callT = lowerer.typeOf(call);
const result = lowerer.mapTypeOf(callT);
let outElem = result?.kind === "array" ? result.elem : elem;
@@ -191,23 +237,31 @@ function filterResultElem(
"keep the receiver's element type instead — annotate the callback's return ': boolean' " +
"(the checker then skips the predicate) or annotate the result with the receiver's own " +
"element type — and narrow the elements after";
if (!booleanFilter) {
const written =
!booleanFilter &&
((!ts.isArrowFunction(argNode) && !ts.isFunctionExpression(argNode)) ||
argNode.type !== undefined);
const narrowing = booleanFilter
? null
: (): string | null =>
checkedFilterNarrowing(lowerer, elem, outElem, filterTypeText(lowerer, call), locOf(call));
if (written) {
const helper = narrowing!();
if (helper !== null) return helper;
if (!ts.isArrowFunction(argNode) && !ts.isFunctionExpression(argNode)) {
lowerer.unsupported(
"SC1090",
argNode,
`narrowing '.filter' through a callback VALUE (only an inline callback whose predicate ` +
`the checker inferred can re-tag — ${annotateEscape})`,
);
}
if (argNode.type) {
lowerer.unsupported(
"SC1090",
argNode,
`narrowing '.filter' with a hand-written type predicate (a written 'x is T' is an ` +
`unchecked assertion nothing validates at runtime — ${annotateEscape})`,
`narrowing '.filter' to '${lowerer.fmt(outElem)}' through a callback VALUE (no checked ` +
`conversion from '${lowerer.fmt(elem)}' — ${annotateEscape})`,
);
}
lowerer.unsupported(
"SC1090",
argNode,
`narrowing '.filter' to '${lowerer.fmt(outElem)}' with a hand-written type predicate (no ` +
`checked conversion from '${lowerer.fmt(elem)}' — ${annotateEscape})`,
);
}
const classRefinement =
!booleanFilter && outElem.kind === "object" && canAssertClassValue(lowerer, valueT, outElem);
@@ -215,6 +269,8 @@ function filterResultElem(
!classRefinement &&
(valueT.kind !== "union" || lowerer.armTag(valueT.unionId, outElem) < 0)
) {
const helper = narrowing?.() ?? null;
if (helper !== null) return helper;
const multiArm = outElem.kind === "union";
lowerer.unsupported(
"SC1090",
@@ -2650,6 +2706,7 @@ export function lowerTuplePredicateCall(
// the value type an array find narrows from.
let foundT: IrType = elem;
let retag: string | null = null;
let narrowing: { helper: string; elem: IrType } | null = null;
if (method === "filter") {
// The element union is built in position order, so the checker's
// result element can list the same arms in another order.
@@ -2660,9 +2717,11 @@ export function lowerTuplePredicateCall(
checkerResult?.kind === "array" &&
arms(checkerResult.elem).length === arms(elem).length &&
arms(checkerResult.elem).every((arm) => arms(elem).some((other) => typeEquals(arm, other)));
outElem = unchanged
? elem
: filterResultElem(lowerer, call, argNode, elem, elem, booleanFilter);
if (!unchanged) {
const filtered = filterResultElem(lowerer, call, argNode, elem, elem, booleanFilter);
outElem = filtered.elem;
narrowing = filtered.narrowing;
}
resultT = arrayOf(outElem);
} else if (method === "find") {
resultT = lowerer.irTypeOf(call);
@@ -2795,5 +2854,19 @@ export function lowerTuplePredicateCall(
loc,
});
}
return { kind: "call", callee: helper, args: [receiver, fnArg], type: resultT, loc };
const result: IrExpr = {
kind: "call",
callee: helper,
args: [receiver, fnArg],
type: resultT,
loc,
};
if (narrowing === null) return result;
return {
kind: "call",
callee: narrowing.helper,
args: [result],
type: arrayOf(narrowing.elem),
loc,
};
}
@@ -0,0 +1,231 @@
/* Checked narrowing for `.filter` with a written type predicate.
*
* A written predicate (`(p): p is Cat => ...`, or a guard function passed
* as a value) is an assertion TypeScript does not check, so the filter loop
* keeps the receiver's element type and the result converts to the
* predicate's type afterwards, element by element:
*
* - a union element to one of its arms, or to a union of some of its
* arms, by its tag;
* - 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.
*
* Each conversion throws the catchable TypeError of a checked extraction
* when a kept element is not of the predicate's type, which is when the
* predicate returned true for a value it does not describe. */
import { varRef } from "../../../ir/build.js";
import {
BOOL,
F64,
STRING,
isUnitType,
typeEquals,
type IrExpr,
type IrStmt,
type IrType,
type SrcLoc,
} from "../../../ir/ir.js";
import { checkedClassAssertion } from "../class-assertions.js";
import { buildArrayConversion } from "../coercions/builders.js";
import type { Lowerer } from "../lowerer.js";
import { keyedHelperName } from "../stable-names.js";
import { typeKey } from "../../type-mapper.js";
/** The interned helper converting a `from[]` filter result to `to[]`, or
* null when an element of `from` has no checked conversion to `to`.
* `typeText` names the predicate's type in the TypeError. */
export function checkedFilterNarrowing(
lowerer: Lowerer,
from: IrType,
to: IrType,
typeText: string,
loc: SrcLoc,
): string | null {
const element = elementHelper(lowerer, from, to, loc, typeText);
if (element === null) return null;
const key = `filter.narrow:${typeKey(from)}:${typeKey(to)}:${typeText}`;
const existing = lowerer.valueHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, "%arr.narrow", key);
lowerer.valueHelpers.set(key, name);
const outT: IrType = { kind: "array", elem: to };
lowerer.liftedFns.push(
buildArrayConversion(
name,
{ kind: "array", elem: from },
{ kind: "arrayLit", elems: [], type: outT, loc },
(value, _index, result) => ({
kind: "arrIntrinsic",
method: "push",
receiver: result,
args: [{ kind: "call", callee: element, args: [value], type: to, loc }],
type: F64,
loc,
}),
loc,
),
);
return name;
}
/** The interned helper converting one `from` value to `to`, or null. */
function elementHelper(
lowerer: Lowerer,
from: IrType,
to: IrType,
loc: SrcLoc,
typeText = lowerer.fmt(to),
): string | null {
const key = `filter.narrow.value:${typeKey(from)}:${typeKey(to)}:${typeText}`;
const existing = lowerer.valueHelpers.get(key);
if (existing) return existing;
// A recursive type pair converts through the helper being built.
if (lowerer.coercions.planning.has(key)) return null;
lowerer.coercions.planning.add(key);
const input = varRef("v.0", from, loc);
let body: IrStmt[] | null;
try {
body = conversionBody(lowerer, input, from, to, typeText, loc);
} finally {
lowerer.coercions.planning.delete(key);
}
if (body === null) return null;
const name = keyedHelperName(lowerer, "%narrow.checked", key);
lowerer.valueHelpers.set(key, name);
lowerer.liftedFns.push({
name,
params: [{ localId: "v.0", name: "v", type: from }],
returnType: to,
locals: [{ id: "v.0", name: "v", type: from, mutable: false }],
body,
loc,
});
return name;
}
/** A converted value: `value` itself when the types are equal, or a call of
* the conversion helper. */
function converted(
lowerer: Lowerer,
value: IrExpr,
from: IrType,
to: IrType,
loc: SrcLoc,
): IrExpr | null {
if (typeEquals(from, to)) return value;
const helper = elementHelper(lowerer, from, to, loc);
return helper === null ? null : { kind: "call", callee: helper, args: [value], type: to, loc };
}
/** The TypeError a kept element of another type throws. */
function typeError(message: string, loc: SrcLoc): IrStmt {
return {
kind: "throw",
value: {
kind: "libCall",
fn: "error.new",
args: [{ kind: "strLit", value: message, type: STRING, loc }],
type: { kind: "object", className: "%TypeError" },
loc,
},
loc,
};
}
function conversionBody(
lowerer: Lowerer,
input: IrExpr,
from: IrType,
to: IrType,
typeText: string,
loc: SrcLoc,
): IrStmt[] | null {
if (typeEquals(from, to)) return [{ kind: "return", value: input, loc }];
if (from.kind === "dyn")
return [{ kind: "return", value: { kind: "dynCheck", value: input, type: to, loc }, loc }];
if (from.kind === "object" && to.kind === "object") {
const asserted = checkedClassAssertion(lowerer, input, to, loc);
return asserted === null ? null : [{ kind: "return", value: asserted, loc }];
}
if (from.kind === "union")
return unionConversion(lowerer, input, from.unionId, to, typeText, loc);
if (from.kind === "record" && to.kind === "record") {
const source = lowerer.shapes.get(from.shapeId);
const target = lowerer.shapes.get(to.shapeId);
if (
!source ||
!target ||
source.tuple !== target.tuple ||
source.indexValue ||
target.indexValue
)
return null;
if (source.fields.length !== target.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);
if (!own) return null;
const read: IrExpr = {
kind: "recordGet",
obj: input,
shapeId: from.shapeId,
field: field.name,
type: own.type,
loc,
};
const value = converted(lowerer, read, own.type, field.type, loc);
if (value === null) return null;
fields.push({ name: field.name, value });
}
return [{ kind: "return", value: { kind: "recordLit", fields, type: to, loc }, loc }];
}
return null;
}
/** 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. */
function unionConversion(
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 targetArms = to.kind === "union" ? (lowerer.unions.get(to.unionId)?.arms ?? null) : [to];
if (targetArms === null) return null;
const body: IrStmt[] = [];
let kept = 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;
kept++;
const payload: IrExpr = isUnitType(arm)
? { kind: "unitLit", unit: arm.kind === "undefinedT" ? "undefined" : "null", type: arm, loc }
: { kind: "unionNarrow", unionId, tag, value: input, type: arm, loc };
const value: IrExpr =
to.kind === "union"
? { kind: "unionWrap", unionId: to.unionId, tag: targetTag, value: payload, type: to, loc }
: payload;
body.push({
kind: "if",
cond: { kind: "unionIsTag", unionId, tag, negated: false, value: input, type: BOOL, loc },
then: [{ kind: "return", value, loc }],
else_: null,
loc,
});
}
// Every arm the target names must be one of the union's own arms.
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)`,
loc,
),
);
return body;
}
@@ -6668,6 +6668,15 @@ export function lowerFilterNarrowCall(
}
const narrowed = outElem !== null && !typeEquals(outElem, elem);
if (!narrowed && !isBooleanArg) return null;
// A written predicate (`(p): p is Cat => ...`, a guard function value)
// proves nothing at run time: the generic array HOF path filters the
// receiver's elements and converts the kept ones checked
// (filterResultElem).
if (
!isBooleanArg &&
((!ts.isArrowFunction(argNode) && !ts.isFunctionExpression(argNode)) || argNode.type)
)
return null;
const annotateEscape =
"keep the receiver's element type instead — annotate the callback's return ': boolean' " +
@@ -6679,6 +6688,9 @@ export function lowerFilterNarrowCall(
lowerer.badType(call, callT); // defensive: a narrowed non-union receiver
}
tag = lowerer.armTag(elem.unionId, outElem);
// Several kept arms: the generic path converts the kept elements to
// the narrower union (filterResultElem).
if (tag < 0 && !isBooleanArg) return null;
if (tag < 0) {
lowerer.unsupported(
"SC1090",
@@ -6700,25 +6712,8 @@ export function lowerFilterNarrowCall(
return { kind: "call", callee: helper, args: [receiver], type: arrayOf(outElem ?? elem), loc };
}
// Inferred type predicate: inline function literal, NO return
// annotation (a written one is an unchecked assertion), and the
// checker reports a predicate over parameter 0.
if (!ts.isArrowFunction(argNode) && !ts.isFunctionExpression(argNode)) {
lowerer.unsupported(
"SC1090",
argNode,
`narrowing '.filter' through a callback VALUE ` +
`(only an inline callback whose predicate the checker inferred can re-tag — ${annotateEscape})`,
);
}
if (argNode.type) {
lowerer.unsupported(
"SC1090",
argNode,
`narrowing '.filter' with a hand-written type predicate ` +
`(a written 'x is T' is an unchecked assertion nothing validates at runtime — ${annotateEscape})`,
);
}
// An inferred type predicate: an inline function literal with no return
// annotation, over parameter 0 (written predicates returned above).
// The receiver evaluates FIRST, in the enclosing function, like JS.
const receiver = lowerer.lowerExpr(access.expression);
const fnArg = lowerer.lowerExpr(argNode);
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7007-filter-written-type-predicates.ts"
],
"diags": []
}
@@ -1,6 +0,0 @@
{
"order": [
"<repo>/tests/diagnostics/filter-narrow.ts"
],
"diags": []
}
@@ -0,0 +1,87 @@
// `.filter` with a written type predicate: an annotated callback
// (`(p): p is Cat => ...`) or a guard function passed as a value. The
// result holds the predicate's type: a union arm, a union of arms, a
// number out of `unknown`, a record or tuple with a narrower field, or a
// subclass. An inferred predicate that keeps several arms narrows too.
interface Cat {
kind: "cat";
meow: string;
}
interface Dog {
kind: "dog";
bark: number;
}
interface Fish {
kind: "fish";
fins: number;
}
type Pet = Cat | Dog | Fish;
const pets: Pet[] = [
{ kind: "cat", meow: "m" },
{ kind: "dog", bark: 2 },
{ kind: "fish", fins: 3 },
{ kind: "dog", bark: 5 },
];
const cats = pets.filter((p): p is Cat => p.kind === "cat");
console.log(cats.length, cats[0]!.meow);
const furry = pets.filter((p): p is Cat | Dog => p.kind !== "fish");
console.log(furry.map((p) => (p.kind === "cat" ? p.meow : p.bark)).join(","));
const notFish = pets.filter((p) => p.kind !== "fish");
console.log(notFish.length);
function isDog(p: Pet): p is Dog {
return p.kind === "dog";
}
console.log(pets.filter(isDog).map((d) => d.bark * 10).join(","));
const isNumber = (v: unknown): v is number => typeof v === "number";
const values: unknown[] = JSON.parse('[1, "a", 2, null, 4]');
console.log(values.filter(isNumber).reduce((sum, n) => sum + n, 0));
const rows: unknown[] = JSON.parse('[{"id":1},{"id":"x"},{"id":3}]');
const withIds = rows.filter(
(r): r is { id: number } => typeof (r as { id: unknown }).id === "number",
);
console.log(withIds.map((r) => r.id + 1).join(","));
const entries: [string, unknown][] = [
["a", 1],
["b", "x"],
["c", 3],
];
const numeric = entries.filter((e): e is [string, number] => typeof e[1] === "number");
console.log(numeric.map(([key, value]) => `${key}${value * 2}`).join(" "));
class Shape {
name: string;
constructor(name: string) {
this.name = name;
}
}
class Circle extends Shape {
r: number;
constructor(r: number) {
super("circle");
this.r = r;
}
}
const shapes: Shape[] = [new Shape("square"), new Circle(2), new Circle(3)];
const circles = shapes.filter((s): s is Circle => s instanceof Circle);
console.log(circles.length, circles.map((c) => c.r).join("+"));
const mixed: (string | number | boolean)[] = ["a", 1, true, "b"];
console.log(mixed.filter((v): v is string => typeof v === "string").join(""));
console.log(mixed.filter((v): v is string | number => typeof v !== "boolean").length);
const pair: [string | number, string | number] = ["x", 4];
console.log(pair.filter((v): v is number => typeof v === "number").map((n) => n + 1).join());
const optional: (string | number | undefined)[] = ["a", 1, undefined, 2];
const defined = optional.filter((v) => v !== undefined);
console.log(defined.length, defined.map((v) => typeof v).join(","));
function isText(v: string | undefined): v is string {
return typeof v === "string";
}
const maybe: (string | undefined)[] = ["a", undefined, "b"];
console.log(maybe.filter(isText).join("-"));
-22
View File
@@ -1,22 +0,0 @@
// Narrowing filters re-tag ONLY what the runtime test proved: an inferred
// single-arm predicate or filter(Boolean). Everything else stays fenced —
// multi-arm targets (the re-tag doesn't exist), hand-written predicates
// (unchecked assertions), and predicate VALUES (their annotation is
// written by definition).
const xs: (string | number | undefined)[] = ["a", 1, undefined];
// Multi-arm narrowing: string | number is not a single arm.
const defined = xs.filter((x) => x !== undefined);
console.log(defined.length);
// A hand-written predicate on an inline callback.
const ys: (string | undefined)[] = ["a", undefined];
const strings = ys.filter((x): x is string => typeof x === "string");
console.log(strings.length);
// A predicate VALUE (declared function with a written predicate).
function isString(x: string | undefined): x is string {
return typeof x === "string";
}
const viaValue = ys.filter(isString);
console.log(viaValue.length);
@@ -1,20 +0,0 @@
filter-narrow.ts:9:17 - error SC1090: '.filter' narrowing 'number | string | undefined' elements to the multi-arm 'number | string' (only a SINGLE arm re-tags — keep the receiver's element type instead — annotate the callback's return ': boolean' (the checker then skips the predicate) or annotate the result with the receiver's own element type — and narrow the elements after) is not supported yet
8 | // Multi-arm narrowing: string | number is not a single arm.
9 | const defined = xs.filter((x) => x !== undefined);
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
10 | console.log(defined.length);
filter-narrow.ts:14:27 - error SC1090: narrowing '.filter' with a hand-written type predicate (a written 'x is T' is an unchecked assertion nothing validates at runtime — keep the receiver's element type instead — annotate the callback's return ': boolean' (the checker then skips the predicate) or annotate the result with the receiver's own element type — and narrow the elements after) is not supported yet
13 | const ys: (string | undefined)[] = ["a", undefined];
14 | const strings = ys.filter((x): x is string => typeof x === "string");
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
15 | console.log(strings.length);
filter-narrow.ts:21:28 - error SC1090: narrowing '.filter' through a callback VALUE (only an inline callback whose predicate the checker inferred can re-tag — keep the receiver's element type instead — annotate the callback's return ': boolean' (the checker then skips the predicate) or annotate the result with the receiver's own element type — and narrow the elements after) is not supported yet
20 | }
21 | const viaValue = ys.filter(isString);
| ^~~~~~~~
22 | console.log(viaValue.length);
@@ -0,0 +1 @@
{ "plain": 3510, "san": 2873 }