Home > database >  Question concerning types, Maps and subclasses in typescript
Question concerning types, Maps and subclasses in typescript

Time:11-02

I have a Chart class which has several subclasses (BarChart, TimeseriesChart...) which extend Chart. I use a method called buildChart to build these charts. It maps the enum ChartsEnum (for example stackedTimeseries or barChart) to the correct class using a Map:

export function buildChart(type: Charts, data: Array<TimeseriesData>) {
   
   var chartsMap = new Map<Charts, InstantiableAbstractClass<typeof Chart>([
        [Charts.stackedTimeseries, TimeseriesChart],
        [Charts.barChart, BarChart],
    ])
    const chart = chartsMap.get(type)
    return new chart(data).chart;

}

The type InstantiableAbstractClass looks like this:

export declare type InstantiableClass<T> = (new ( ...args: any) => { [x: string]: any }) & T;

For example, the class and constructor of TimeseriesChart looks like this:

export class TimeseriesChart extends Chart{
    constructor(data: Array<TimeseriesData>) {
        super(data);
    }
}

I now want to add a second attribute to the chart-class called options (next to the existing attribute data). The problem is now that options requires for each ChartType (BarChart, TimeseriesChart) different properties. For example BarChart requires these properties:

{
    start: number;
    end?: number;
}

and TimeseriesChart requires a type like this:

{
    description: string;
}

The constructor of TimeseriesChart would then look like this:

export class TimeseriesChart extends Chart{
    constructor(data: Array<TimeseriesData>, options: TimeseriesChartOptions) {
        super(data, options);
    }
}

This means that the method buildChart needs a new argument options which can then be passed to the specific classes (as done with the argument data).

What is the best way for doing this? I thought of using generics and then defining n types of types for options for n subclasses, but I could not figure out on how to change the type InstantiableAbstractClass correctly for that.

You can find a complete example with some descriptions here.

I really appreciate your help! If you need any further information I would be happy to provide them.

Thank you and all the best Lukas

CodePudding user response:

Firstly, it seems like you want your Chart class to be abstract since you are only ever going to use concrete subclasses of it, and you want the buildChart() method to throw if not overridden. I'm also going to use parameter properties as a shorthand for declaring a field and an idential constructor parameter and assigning the latter to the former. I'm adding an options parameter of the any type because we don't really need any type checking in the base class like this.

abstract class Chart {
    constructor(protected data: Array<TimeseriesData>, protected options: any) { }
    abstract buildChart(): void;
}

Anyway, for the subclasses, we will narrow the options property to the appropriate type by using the declare property modifier, and specify that type in the constructor parameter also:

interface BarChartOptions {
    start: number,
    end: number
}
class BarChart extends Chart {
    declare options: BarChartOptions;
    constructor(data: Array<TimeseriesData>, options: BarChartOptions) {
        super(data, options);
    }
    buildChart() { return {}; }
    protected getChartData() { }
}


interface TimeseriesChartOptions {
    description: string
}

class TimeseriesChart extends Chart {
    declare options: TimeseriesChartOptions;
    constructor(data: Array<TimeseriesData>, options: TimeseriesChartOptions) {
        super(data, options);
    }
    buildChart() { return { }; }
    protected getChartData() { }
}

Now we need to express the relationship between your Charts enum and the different subclasses. Let's make a chartConstructors object holding the enums as keys and the constructors as values:

const chartConstructors = {
    [Charts.stackedTimeseries]: TimeseriesChart,
    [Charts.barChart]: BarChart
}

You should add an entry to that for each subclass of Chart you care about.


Finally we will write your buildChart() function. Let's make some helper types:

type ChartConstructorParameters<C extends Charts> = 
  ConstructorParameters<typeof chartConstructors[C]>;
type ChartInstance<C extends Charts> = 
  InstanceType<typeof chartConstructors[C]>;
type ChartConstructor<C extends Charts> = 
  new (...args: ChartConstructorParameters<C>) => ChartInstance<C>;

The ChartConstructorParameters<C> type will take an enum member as C and turn it into the tuple of arguments to the relevant class constructor, using the ConstructorParameters<T> utility type.

The ChartInstance<C> type does the same thing for the instance type of the relevant class constructor, using the InstanceType<T> utility type.

And finally the ChartConstructor<C> represents the relevant constructor signature for the class.

And now here's buildChart():

function buildChart<C extends Charts>(
  type: C, ...ctorArgs: ChartConstructorParameters<C>
) {
    const chartConstructor = chartConstructors[type] as ChartConstructor<C>;
    return new chartConstructor(...ctorArgs);
}

It's a generic function which takes a type parameter of generic type C corresponding to an enum member. And for the remaining arguments, it takes whatever the constructor parameters of the relevant class is. This will be a pair of data and options for the BarChart and TimeseriesChart, but if you add subclasses with other constructor parameters, it will accept those.

The output is an instance of the class, which it constructs by grabbing the relevant constructor from chartConstructors (we need to use a type assertion to convince the compiler that chartConstructors[type] is actually of the type ChartConstructor<C>; it's something the compiler is unable to see due to its inability to reason too much about unspecified generic types like C).


So, does it work?

const barChart = buildChart(Charts.barChart, [], { start: 1, end: 2 });
// const barChart: BarChart

const timeSeriesChart = buildChart(Charts.stackedTimeseries, [], { description: "" });
// const timeSeriesChart: TimeseriesChart

Yes, looks good!

Playground link to code

  • Related