I want to apply a chain of filters to an array and narrow the type accordingly. The array elements belong to a union type with some shared properties.
type TypeA = {name: string; id: string; createdAt: Date};
type TypeB = {name: string; id: string; metaData: Record<string, unknown>};
type TypeC = {id: string};
type TypeD = {name: string; createdAt: Date};
// etc.
type MyUnionType = TypeA | TypeB | TypeC | TypeD;
const hasName = (x: MyUnionType): x is MyUnionType & {name: string} => 'name' in x;
const hasId = (x: MyUnionType): x is MyUnionType & {id: string} => 'id' in x;
function foo(ary: MyUnionType[]) {
ary.filter(hasName)
.filter(hasId)
.map(x => x.name + x.id); // ❌ Error because it thinks property `id` does not exist
}
I've thought of two workarounds:
function hasNameAndId(x: MyUnionType): x is MyUnionType & {name: string} {
return 'name' in x && 'id' in x;
}
This solution isn't scalable, as it means writing a function for each combination of filters.
function foo(ary: MyUnionType[]) {
ary.filter((x): x is MyUnionType & {name: string} => 'name' in x)
.filter((x: MyUnionType & {name: string}): x is MyUnionType & {name: string; id: string} => 'id' in x)
.map(x => x.name + x.id);
}
This solution gets messy very quickly.
When you call type guard functions directly, the compiler performs the sort of narrowing you're looking for automatically:
function foo(ary: MyUnionType[]) {
ary.flatMap(x => hasName(x) && hasId(x) ? x.name + x.id : [])
}
But in order for Array.filter()
to work as a type guard function you need to pass in a callback that exactly matches the relevant call signature:
interface Array<T> {
map<U>(cb: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
}
And unfortunately that's not what's happening after you call ary.filter(hasName).filter(hasId)
, where the array element is of type MyUnionType & { name: string }
, but your callback takes an argument of type MyUnionType
. Those aren't seen as compatible enough, so the type guard is skipped and you just get the "normal" filter()
behavior where the output type doesn't change.
The most straightforward way around this, I think, is to make your type guard functions generic so that the call to the second filter()
can instantiate the generic type parameter accordingly. Something like this:
const hasName = <T extends MyUnionType>(x: T): x is T & { name: string } => 'name' in x;
const hasId = <T extends MyUnionType>(x: T): x is T & { id: string } => 'id' in x;
And then the second filter()
call works; T
will be instantiated with MyUnionType & {name: string}
, and the return type is an array of (MyUnionType & {name: string}) & {id: string})
, as desired:
function foo(ary: MyUnionType[]) {
ary.filter(hasName)
.filter(hasId)
.map(x => x.name + x.id); // okay
}