typescripttypesstructural-typing

"Structural type guard" works with `if`, but not as array filter predicate


I have a union type (Pet in the example below) that combines multiple object types which each have a type property indicating their type. Sometimes I have an array of the union type (Pet[]) and need to .filter() it based on the type property. That in itself works perfectly fine, but in order to avoid redundant type declarations I want to make sure that the result of the .filter() call is automatically typed properly.

User-defined type guards seemed like the perfect solution for this, so I implemented one that just checks the type property and narrows the type to { type: 'something' } without explicitly declaring the full type (this one's called isCatLike below). I tried using it inside an if and it correctly narrowed my type from Pet to Cat.

I then tried to use it as a predicate for .filter() and this time the type wasn't narrowed at all. The resulting array was still typed as Pet[] although my if experiment had shown that the type guard was generally able to narrow from Pet to Cat.

As another experiment I tried to change the type guard slightly and make the type predicate more explicit (is Cat instead of is { type: 'cat' } and suddenly the .filter() call correctly narrowed the type from Pet[] to Cat[] (this function is called isCat below).

type Cat = { type: 'cat'; name: string; purrs: boolean }
type Dog = { type: 'dog'; name: string; woofs: boolean }
type Pet = Cat | Dog

declare const pets: Pet[]
const isCatLike = (pet: any): pet is { type: 'cat' } => pet.type === 'cat'
const isCat = (pet: Pet): pet is Cat => pet.type === 'cat'

for (const pet of pets) {
  if (isCatLike(pet)) {
    pet // Cat -> Correct!
  }
  if (isCat(pet)) {
    pet // Cat
  }
}

const catLikes = pets.filter(isCatLike)
catLikes // Pet[] -> Incorrect!

const cats = pets.filter(isCat)
cats // Cat[]

Open the example on the TypeScript Playground to inspect the types yourself.

The problem now is that I can't use the more explicit approach (illustrated by the isCat function) because my actual code has a lot more types in the union and there the predicate is also created by a function (isType(type: string)).

So what I'm wondering at the moment is this:

Why does my "structural type guard" work in the context of an if statement, but not as a predicate for filtering an array? Shouldn't it work exactly the same way in both cases? Am I doing something wrong or have I hit a limitation of the type system?


Solution

  • This boils down to how the types for Array.filter are written:

    interface Array<T>
        // This is the signature that enables the array to be a different type
        filter<S extends T>(predicate: (value: T, index: number, array: T[]) => value is S, thisArg?: any): S[];
    
        // Normal signature that returns the same type of array
        filter(predicate: (value: T, index: number, array: readonly T[]) => unknown, thisArg?: any): T[];
    }
    

    The key bit of that signature is <S extends T>, where in this case, T is Pet and S would be { type: "cat" }. However, { type: "cat" } does not extend Pet, so the signature does not apply, so it falls into the normal filter signature.


    It works in the single element case, because TS's narrowing logic is actually a bit smarter than can be expressed in the signature for .filter - it actually combines the result of the type guard and the original type - something like Pet & { type: "cat" } which is the same as Cat.