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?
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
.