Reputation: 3338
I'm having trouble filtering an array that's of a union type into a single type.
I've got code very similar to this example:
interface Section {
type: 'section';
name: string;
children: (Section | Link)[];
}
interface Link {
type: 'link';
title: string;
children: []
}
const json = JSON.stringify({
type: 'section',
name: 'Parent',
children: [
{ type: 'section', name: 's1', children: [] },
{ type: 'section', name: 's2', children: [{ type: 'section', name: 's2', children: [] }] },
{ type: 'link', title: 'l1', children: [] },
{ type: 'section', name: 's3', children: [] },
{ type: 'link', title: 'l2', children: [] },
]
});
let parentSection: Section | Link = JSON.parse(json);
const sections = parentSection.children.filter((item): item is Section => !!item && item.type === 'section');
JSON.stringify
and JSON.parse
are there so TS doesn't infer the type of parentSection
from the data, which comes from an API in real world.
The problem is that the type of sections
as inferred by TS is (Section | Link)[]
, but I'd expect it to be Section[]
or Section[] | []
.
What do I need to change here to make this work as I expect/want it to work? I can't affect the data itself, but I am the one writing types for it, so I change play with those. The Link
items always come with the children
array without any items in it.
I'd really appreciate some help. Thank you!
Upvotes: 4
Views: 3545
Reputation: 327819
The issue here is that you have a value of union type (Section | Link)[] | []
and you're trying to call its filter()
method, which is therefore also a union of function types. Taking a union of functions with differing call signatures and deciding which calls are safe is not a trivial task. Conceptually a union of call signatures should take an intersection of the parameters (due to contravariance of function/method parameters), but there are a lot of practical issues that make it harder.
Originally, TypeScript just gave up and didn't let you call such functions at all. See microsoft/TypeScript#7294. Then TypeScript 3.3 introduced support for calling some union types via a fix in microsoft/TypeScript#29011, which would synthesize a single call signature using the intersections of the parameters from the individual union members' call signatures, as long as no more than one member's call signature is overloaded and no more than one member's call signature is a generic. This improved things a lot, but in particular array methods tend to be generic and/or overloaded so unions of arrays still had few useful methods available. See microsoft/TypeScript#36390. A little more work has been done in microsoft/TypeScript#31023 to allow some generic methods to be called in unions, and as of now, only reduce()
is completely uncallable for unions of functions. Still, it's not perfect, and
microsoft/TypeScript#44373 is the currently open issue about this. Your filter()
call is specifically trying to use the overloads where the callback is a user-defined type guard function, and that's just not supported at this time.
So for now there are only workarounds. My go-to workaround:
Generally speaking when you have an array of type Array<A> | Array<B>
and you're only going to be reading from it, it should be safe to treat it as a value of type ReadonlyArray<A | B>
. In fact, the compiler will generally let you perform this widening:
parentSection.children; // (Section | Link)[] | []
const pc: readonly (Section | Link)[] = parentSection.children; // no error
Note that the element type for []
is the never
type, so ReadonlyArray<A | B>
in this case would be ReadonlyArray<(Section | Link) | never>
which is just ReadonlyArray<Section | Link>
.
Once you have a ReadonlyArray<A | B>
, it's no longer a union type (its elements are union typed but not the array itself) and you can call filter()
with impunity:
const sections = pc.filter((item): item is Section => !!item && item.type === 'section');
// const sections: Section[]
Note that you could shorten this to a one-liner:
const sections = (parentSection.children as readonly (Section | Link)[]).
filter((item): item is Section => !!item && item.type === 'section');
// const sections: Section[]
but that's a bit less type safe since type assertions allow (unsafe) narrowing/downcasting as well as (safe) widening/upcasting, whereas type annotations on variables only allows widening/upcasting.
There are, of course, other viable workarounds (e.g., you mentioned using map(x => x)
, which works because of some of the above PRs, and then it becomes a single array type). Until and unless microsoft/TypeScript#44373 is addressed, you'll have to stick with your favorite workaround.
Upvotes: 6