gligoran
gligoran

Reputation: 3338

Filter an array with typegaurd doesn't produce an array of that type

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');

TypeScript Playground

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

Answers (1)

jcalz
jcalz

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.

Playground link to code

Upvotes: 6

Related Questions