I thought I had found the best way to define predicates:
declare function isNumber<T>(x: T): x is Extract<T, number>;
declare function isFunction<T>(x: T): x is Extract<T, Function>;
... and so on
This approach results in nicely narrowed types when used to filter arrays, for example:
type Handler = () => void;
declare const a: (number|string)[];
declare const b: string[];
declare const c: (Handler|null)[];
const a1 = a.filter(isNumber); // number[]
const b1 = b.filter(isNumber); // never[]
const c1 = c.filter(isFunction); // Handler[]
Unfortunately, unknown and any result in surprising behavior:
declare const d: any[];
declare const e: unknown[];
const d1 = d.filter(isNumber); // any[] want number[]
const e1 = e.filter(isNumber); // never[] want number[]
So it wasn't the best way after all! However, even the approach in the Typescript handbook for defining predicates behaves "weirdly" when used for filtering:
declare function isNumber2(x: any): x is number;
declare function isFunction2(x: any): x is Function;
const a2 = a.filter(isNumber2); // number[]
const b2 = b.filter(isNumber2); // string[] want never[]
const c2 = c.filter(isFunction2); // (Handler|null)[] want Handler[]
const d2 = d.filter(isNumber2); // number[]
const e2 = e.filter(isNumber2); // number[]
I've spent way too long trying various approaches, like overloads, generic param constraints, etc., to get this to work for all of the above cases, but getting nowhere. Is there a way to define predicates that nicely narrow arrays when filtered? (Sorry, I know "nicely" is subjective. Generally it would be the most narrow intended type.) Or do I just need to choose an approach that is tolerable? Also looking for guidance that I'm totally on the wrong path as I'm currently learning Typescript.
Disclaimer: TypeScript's narrowing behavior is essentially a mixture of various heuristics that work over a wide range of real-world use cases; there will always be edge cases where you'd like it to act differently. Trying to preemptively account for all these edge cases in a one-size-fits-all type guard function is going to involve a lot of fiddly type juggling, and result in an unintuitive and complicated call signature. And there will still be edge cases, so at some point it's not worth the diminishing returns you get by accounting for them. In what follows I will describe one possible approach which handles the cases from the example code in the question, but just because it's possible does not imply it's advisable. That depends on use cases, and is ultimately out of scope for the question as asked.
My goal is to produce a utility type
TypePred<T, U>such thatbehaves as you desire. First, no matter what, TypeScript needs to see
TypePred<T, U>as assignable toT, even for genericT. The easiest way to do that is to use theExtractutility type on the result of our intended narrowing:This will have no ultimate effect as long as the
⋯type is indeed assignable toTwhen you fill in some type argument forT, but it will prevent any compiler errors in the type predicate.Now we'll take care of the
anytype. Just about any type manipulation involvinganywill evaluate toany; it's "infectious". Bothany & Xandany | Xevaluate toany. So it takes care not to haveTypePred<any, U>evaluate toany. There's a technique described in the answer to Disallow call with any for detectingany, which is essentially just witnessing the weird behavior and detecting a supposedly impossible situation of an intersection widening a type. IfTisanythen we just want to returnU. So now we have:Now we can finally start dealing with more general cases. The
Extractutility type is a distributive conditional type that serves to filter union types. It looks likeT extends U ? T : never. The? Tpart of that is good. Every union member ofTthat's a subtype ofUcan be returned as-is. It's just that theneverpart ends up dropping things which have some overlap withU, likeunknown. So now we have:So, finally, we have to deal with
T(or any union member ofT) that is not already a subtype ofU, but which might have some overlap withU, and produce a subtype ofTandUfrom it. The only facility TS has for this in general is the intersectionT & U.Which gives us
Let's test it out on your examples:
Looks good, that's the behavior you wanted.
Playground link to code