Search code examples
typescripttypespredicate

What is the best way to define Typescript type predicates that result in the most narrow types when used to filter arrays?


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[]         šŸ‘

playground

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.


Solution

  • 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 that

    declare function isNumber<T>(x: T): x is TypePred<T, number>;
    declare function isFunction<T>(x: T): x is TypePred<T, Function>
    

    behaves as you desire. First, no matter what, TypeScript needs to see TypePred<T, U> as assignable to T, even for generic T. The easiest way to do that is to use the Extract utility type on the result of our intended narrowing:

    type TypePred<T, U> =
        Extract<ā‹Æ, T>;
    

    This will have no ultimate effect as long as the ā‹Æ type is indeed assignable to T when you fill in some type argument for T, but it will prevent any compiler errors in the type predicate.

    Now we'll take care of the any type. Just about any type manipulation involving any will evaluate to any; it's "infectious". Both any & X and any | X evaluate to any. So it takes care not to have TypePred<any, U> evaluate to any. There's a technique described in the answer to Disallow call with any for detecting any, which is essentially just witnessing the weird behavior and detecting a supposedly impossible situation of an intersection widening a type. If T is any then we just want to return U. So now we have:

    type TypePred<T, U> =
        Extract<0 extends (1 & T) ? ā‹Æ, T>;
    

    Now we can finally start dealing with more general cases. The Extract utility type is a distributive conditional type that serves to filter union types. It looks like T extends U ? T : never. The ? T part of that is good. Every union member of T that's a subtype of U can be returned as-is. It's just that the never part ends up dropping things which have some overlap with U, like unknown. So now we have:

    type TypePred<T, U> =
        Extract<0 extends (1 & T) ? U : T extends U ? T : ā‹Æ, T>;
    

    So, finally, we have to deal with T (or any union member of T) that is not already a subtype of U, but which might have some overlap with U, and produce a subtype of T and U from it. The only facility TS has for this in general is the intersection T & U.

    Which gives us

    type TypePred<T, U> =
        Extract<0 extends (1 & T) ? U : T extends U ? T : (T & U), T>;
    

    Let's test it out on your examples:

    declare const a: (number | string)[];
    const a1 = a.filter(isNumber); // number[]  
    
    declare const b: string[];
    const b1 = b.filter(isNumber); // never[]   
    
    type Handler = () => void;
    declare const c: (Handler | null)[];
    const c1 = c.filter(isFunction);// Handler[] 
    
    declare const d: any[];
    const d1 = d.filter(isNumber); // number[]
    
    declare const e: unknown[];
    const e1 = e.filter(isNumber); // number[]
    

    Looks good, that's the behavior you wanted.

    Playground link to code