1

Typescript version 3.0.3.

I'm creating a model for my sidebar navigation menu, item can be one of 2 types as described below:

type SidebarItems = Array<SimpleSidebarItem | ComplexSidebarItem>;

abstract class SidebarItem {
    title: string;
}

class SimpleSidebarItem extends SidebarItem {
    url : string;
}

class ComplexSidebarItem extends SidebarItem {
    subItems: Array<{
        title: string;
        url : string;
    }>
}
  • If it's a SimpleSidebarItem, it must have a url, but no subItems.
  • It it's a ComplexSidebarItem, it shouldn't have url, but must have subItems.

I can't get this working right - this shouldn't be a valid input, but it shows ok:

const items: SidebarItems = [{title: '', url: '', subItems: [{title: '', url: ''}]}];

Inference don't work as expected:

const items: SidebarItems = [{title: '', url: ''}, {title: '', subItems: [{title: '', url: ''}]}];
const shouldBeComplexSidebarItem = items[1];

type of shouldBeComplexSidebarItem is SimpleSidebarItem | ComplexSidebarItem.

What am I missing here?

nabnaf
  • 75
  • 5
  • https://stackblitz.com/edit/typescript-c8qrw1?embed=1&file=index.ts – nabnaf Oct 09 '18 at 13:26
  • What type are you expecting? To me the type inferrence looks correct. An item of `Array` is *supposed* to be `SimpleSidebarItem | ComplexSidebarItem`. – Sefe Oct 09 '18 at 13:33

1 Answers1

4

There are two problems here.

The first one has to do with excess property checks when unions are involved. You can read this answer here to a similar question. The gist of it is that excess property checks for unions allows any key of any member to be present on the object. We can get around this by introducing extra members of type never to make sure that the object with excess properties in not wrongly compatible with a particular member:

type SidebarItems = Array<StrictUnion<SimpleSidebarItem | ComplexSidebarItem>>;

type UnionKeys<T> = T extends any ? keyof T : never;
type StrictUnionHelper<T, TAll> = T extends any ? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>> : never;
type StrictUnion<T> = StrictUnionHelper<T, T>


const items2: SidebarItems = [{title: '', url: '', subItems: [{title: '', url: ''}]}]; //error
const items3: SidebarItems = [{title: '', subItems: [{title: '', url: ''}]}]; //ok

The second problem is related to the fact that typescript will not do any extra inference if you specify the type of a variable, so the information that items[1] is ComplexSidebarItem is lost, all typescript will know is that an item can be SimpleSidebarItem | ComplexSidebarItem .

We can either use a type guard to check the type:

const items: SidebarItems = [{title: '', url: ''}, {title: '', subItems: [{title: '', url: ''}]}];
const shouldBeComplexSidebarItem = items[1];
if(!('url' in shouldBeComplexSidebarItem)){ //type guard
    shouldBeComplexSidebarItem.subItems // is ComplexSidebarItem here 
} 

Or we could use a function to create the array that will infer a tuple type, for which types as at a particular index are known:

function createItems<T extends SidebarItems>(...a:T){
    return a;
}
const items = createItems({title: '', url: ''}, {title: '', subItems: [{title: '', url: ''}]});
const shouldBeComplexSidebarItem = items[1];
shouldBeComplexSidebarItem.subItems // is an object literal compatible with ComplexSidebarItem

You can also manually specify the tuple type, in which case the StrictUnion is not needed anymore:

const items: [SimpleSidebarItem, ComplexSidebarItem] = [{title: '', url: ''}, {title: '', subItems: [{title: '', url: ''}]}];
const shouldBeComplexSidebarItem = items[1];
shouldBeComplexSidebarItem.subItems // is ComplexSidebarItem
Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357