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!