Home > Software design >  TypeScript not inferring correct interface based on property
TypeScript not inferring correct interface based on property

Time:12-07

I keep running into the same "issue" with TS where I have list of objects and each has different properties based on their type. For example:

const widgets = [
  {type: 'chart', chartType: 'line'},
  {type: 'table', columnWidth: 100}
];

I can create interfaces and types for above and it works well, but in the end I always run into issue where TS is not "smart" enough to recognise which type of object I am dealing with. See this playground example

So in the end I always have to do something like

(widget as WidgetChart).chartType

Looking at some SO questions I found something very similar in this answer by @matthew-mullin Which says

Now you can use the Sublist type and it will correctly infer whether it is of type SublistPartners or SublistItem depending on the field values you provide.

but obviously that is not happening in my case.

Am I doing something wrong or am I expecting too much from TS?

CodePudding user response:

I would argue TS is in fact correctly inferring your types based on the code in your linked playground. Your issue is that you are "confusing" TypeScript with this line (*confusing based on what I understand your real intentions to be):

type Widget = WidgetChart | WidgetBase;

Specifically, based on WidgetBase's definition (in your linked playground),

enum WidgetType {
  CHART = 'chart',
  TABLE = 'table'
}
interface WidgetBase {
  type: WidgetType
  title: string
}

{ type: 'chart', title: 'some string' } would be a valid WidgetBase and because Widget = WidgetChart | WidgetBase this object would also be a valid "Widget"! This object matches the switch case of type === 'chart' and does not contain a reference to chartType. So TypeScript is "correctly" warning you that chartType may not always exist on a Widget with a type of 'chart' (because of the way you have defined Widget).

Therefore, to correct it, you would need to provide TS with the actual WidgetTypes only, i.e.:

enum WidgetType {
    CHART = 'chart',
    TABLE = 'table'
}
enum ChartType {
    LINE = 'line',
    BAR = 'bar'
}
interface WidgetBase {
    title: string
    // ... other common, *generic* properties ...
}
interface WidgetTable extends WidgetBase {
    type: WidgetType.TABLE
    columnWidth: number
    // ... other specific properties ...
}
interface WidgetChart extends WidgetBase {
    type: WidgetType.CHART,
    chartType: ChartType
    // ... other specific properties ...
}
type Widget = WidgetChart | WidgetTable; // <-- There are only two choices now: either a valid WidgetChart or a valid WidgetTable, nothing else! You can add more WidgetTypes here in a similar manner of course if you want

function renderWidget(widget: Widget){
    switch(widget.type) {
        case WidgetType.CHART:
            return console.log(widget.chartType); // this works now and doesn't complain :)
        case WidgetType.TABLE:
            return console.log(widget.columnWidth); // so does this! :)
        // you don't _need_ a default here in this case, since you've covered all the WidgetTypes
    }
}

Edit: An alternative ():

Assuming you have many different widget types with identical properties (other than the type) and only a few which need additional properties, you could take a slightly different approach. Just for the sake of the example, the widgets with the same properties but different type would be rendered with different styling, e.g.: type = 'card-big' or type = 'card-small' etc.

In this case you might not want to have to specify every one of the empty extensions to the WidgetBase that you would have to do using the approach above. Instead you could use the Exclude and Omit TS Utility Types. The working code would then look something like this:

enum WidgetType {
    CHART = 'chart',
    TABLE = 'table',
    CARD_BIG = 'card-big',
    CARD_SMALL = 'card-small',
    // ... etc ...
}
interface WidgetBase {
    type: Exclude<WidgetType, WidgetType.CHART | WidgetType.TABLE>, // <-- this is the important bit - A generic widget is any widget *other than* chart or table
    title: string
    // ... other common, *generic* properties ...
}
interface WidgetTable extends Omit<WidgetBase, 'type'> { // <-- extend the base properties but omit the type which would clash otherwise
    type: WidgetType.TABLE
    columnWidth: number
    // ... other specific properties ...
}
interface WidgetChart extends Omit<WidgetBase, 'type'> {
    type: WidgetType.CHART,
    chartType: ChartType
    // ... other specific properties ...
}
type Widget = WidgetChart | WidgetTable | WidgetBase; // <-- specify the special cases   the generic case without confusing TS

function renderWidget(widget: Widget) {
    switch(widget.type) {
        case WidgetType.CHART:
            return console.log(widget.title, widget.chartType); // your specific widgets have both generic and specific properties
        case WidgetType.TABLE:
            return console.log(widget.title, widget.columnWidth); // same as above
        // ... handle other generic widgets if you want, or ...
        default:
            return console.log(widget.title) // all your other widgets would have only the generic properties
    }
}
  • Related