Dmitry
Dmitry

Reputation: 11

Type-safe dictionary in typescript

Is it possible in Typescript to make it possible for me to return a certain type depending on the WidgetType argument? For example - if I specify WidgetType.chart, is widgetFactory assigned the type [ChartView, ChartModel].

    enum WidgetType {
        chart = 0,
        table = 1,
        // etc...
    }

    class ViewBase {}
    class ModelBase {}

    class ChartView extends ViewBase {}
    class ChartModel extends ModelBase {}
    class TableView extends ViewBase {}
    class TableModel extends ModelBase {}


    class Registry<T1 extends ViewBase = ViewBase,T2 extends ModelBase = ModelBase> {
        private dict:{[key in WidgetType]?:[new() => T1,new() => T2]} = {};

        public addWidget(type:WidgetType,view:new() =>T1,model:new() => T2){
            this.dict[type] = [view,model];
        }

        public getWidget(type:WidgetType){
            return this.dict[type];
        }
    }


    const registry = new Registry();


    // Init app ...
    registry.addWidget(WidgetType.chart,ChartView,ChartModel);
    registry.addWidget(WidgetType.table,ChartView,ChartModel);
    //

    const widgetFactory = registry.getWidget(WidgetType.chart);
    if(widgetFactory){
        const view = new widgetFactory[0]();
        const model = new widgetFactory[1]();
    }

Upvotes: 0

Views: 397

Answers (2)

AJP
AJP

Reputation: 28563

Discriminated unions plus type guards using type predicates is what you want:

interface Square {
    kind: "square";
    size: number;
}

interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}
type Shape = Square | Rectangle;

const is_square = (shape: Shape): shape is Square => shape.kind === "square"
const is_rectangle = (shape: Shape): shape is Rectangle => shape.kind === "rectangle"

const shape: Shape = { kind: "square", size: 1 }

function process_shape (shape: Shape)
{
    if (is_square(shape)) console.log(shape.size)
    else console.log(shape.width)
}

Upvotes: 0

jcalz
jcalz

Reputation: 330436

First of all, TypeScript's type system is structural, not nominal, so if you want the compiler to reliably distinguish between two types, they should have different structure. In example code people tend to write empty types like class Foo {} and class Bar {}, but the compiler sees those as the same type, despite the differing names. The solution is to add a dummy property to each type to make them incompatible. So I'm just going to do this:

class ViewBase { a = 1 }
class ModelBase { b = 2 }
class ChartView extends ViewBase { c = 3 }
class ChartModel extends ModelBase { d = 4 }
class TableView extends ViewBase { e = 5 }
class TableModel extends ModelBase { f = 6 }

to avoid any possible issues here.


For TypeScript versions before 3.7, my suggestion would either be:

  • build your registry all at once, such as having a constructor that requires you to put everything in it at the beginning, like this:

    const registry = new Registry({
      WidgetType.chart: [ChartView, ChartModel], 
      WidgetType:table: [TableView, TableModel]
    });
    

    or:

  • have the addWidget() method return a new registry object and require that you use method chaining instead of reusing the original registry object, like this:

    const registry: Registry = new Registry()
      .addWidget(WidgetType.chart, ChartView, ChartModel)
      .addWidget(WidgetType.table, TableView, TableModel);
    

The advantage of these solutions is that the compiler is allowed to give a single, unchanging type to each value. In the first suggestion, there is only the fully-configured registry object. In the second suggestion, there are multiple registry objects in various stages of being configured, but you only use the final one. Either way, the type analysis is straightforward.

But what you want to see is that every time you call addWidget() on a Registry object, the compiler mutates the type of the object to account for the fact that the widget of a particular type has a particular model and view type associated with it. TypeScript doesn't support arbitrarily mutating the type of an existing value. It does support the temporary narrowing of a value's type via control flow analysis, but before TypeScript 3.7 there was no way to say that a method like addWidget() should result in such a narrowing.


TypeScript 3.7 introduced assertion functions which allow you to say that a void-returning function should trigger a specific control-flow narrowing of one of the function's arguments or the object on which you're calling a method (if the function is a method). The syntax for this is to have the return type of the method look like asserts this is ....

Here's one way to give typings to Registry to use an assertion signature with addWidget():

class Registry<D extends Record<WidgetType & keyof D, { v: ViewBase, m: ModelBase }> = {}> {
    private dict = {} as { [K in keyof D]: [new () => D[K]["v"], new () => D[K]["m"]] };

    public addWidget<W extends WidgetType, V extends ViewBase, M extends ModelBase>(
        type: W, view: new () => V, model: new () => M):
        asserts this is Registry<D & { [K in W]: { v: V, m: M } }> {
        (this.dict as any as Record<W, [new () => V, new () => M]>)[type] = [view, model];
    }

    public getWidget<W extends keyof D>(type: W) {
        return this.dict[type];
    }
}

Here I've made Registry generic in a single type parameter D, which represents a dictionary that maps a subset of WidgetType enums to a pair of view and model types. This will default to the empty object type {} so that a value of type Registry means a registry with nothing added to it yet.

The _dict property is of a mapped type that takes D and makes its properties zero-arg constructors in a tuple. Note that the compiler can't know that {} is a valid value of that type, since it doesn't understand that when a Registry object is constructed it will always have a D of {}, so we need a type assertion.

The addWidget() method is a generic assertion method which takes an enum of type W, a view constructor of type V and a model constructor of type M, and narrows the type of this by adding a property to D... changing D to D & { [K in W]: { v: V, m: M } }.

Finally the getWidget() method only lets you specify an enum type that's a key of D. By doing this we can make the properties of the dictionary required and not optional, and getWidget() will never return undefined. Instead, you won't be allowed to call getWidget() with an enum that hasn't already been added.


Let's see it in action. First we create a new Registry:

// need explicit annotation below
const registry: Registry = new Registry();

Here's one of the big caveats of using assertion functions as methods: they only work if the object is of an explicitly annotated type. See microsoft/TypeScript#33622 for more information. If you wrote const registry = new Registry() without the annotation, you'd get errors on addWidget(). This is a pain point and I don't know if or when it'll get better.

With that unpleasantness behind us, let's move on:

registry.getWidget(WidgetType.chart); // error! 
registry.getWidget(WidgetType.table); // error! 

These are errors because the registry has no widgets yet.

registry.addWidget(WidgetType.chart, ChartView, ChartModel);

registry.getWidget(WidgetType.chart); // okay
registry.getWidget(WidgetType.table); // error! registry doesn't have that

Now you can get a chart widget but not a table widget.

registry.addWidget(WidgetType.table, TableView, TableModel);

registry.getWidget(WidgetType.chart); // okay
registry.getWidget(WidgetType.table); // okay

And now you can bet both widgets. Each call to addWidget() has narrowed the type of registry. If you hover over registry and look at the quickinfo from IntelliSense, you can see the evolution of its type from Registry<{}> to Registry<{0: {v: ChartView; m: ChartModel;};}> to Registry<{0: {v: ChartView; m: ChartModel;};} & {1: {v: TableView; m: TableModel;};}>

And now we can use the getWidget() method to get strongly-typed views and models:

const chartWidgetFactory = registry.getWidget(WidgetType.chart);
const chartView = new chartWidgetFactory[0](); // ChartView
const chartModel = new chartWidgetFactory[1](); // ChartModel

const tableWidgetFactory = registry.getWidget(WidgetType.table);
const tableView = new tableWidgetFactory[0](); // TableView
const tableModel = new tableWidgetFactory[1](); // TableModel

Hooray!


So that all works. Still, assertion methods are fragile. You need to use explicit annotations in some places. And all control flow analysis narrowings are temporary and don't persist down into closures (see microsoft/TypeScript#9998):

function sadness() {
    registry.getWidget(WidgetType.chart); // error!
}

The compiler doesn't know and cannot figure out that by the time sadness() is called, that registry will be fully configured. So I'd still probably recommend one of the original solutions. For completeness, I'll show you what the method chaining solution looks like:

    public addWidget<W extends WidgetType, V extends ViewBase, M extends ModelBase>(
        type: W, view: new () => V, model: new () => M) {
        const thiz = this as any as Registry<D & { [K in W]: { v: V, m: M } }>;
        thiz.dict[type] = [view, model];
        return thiz;
    }

The difference: instead of asserts this is XXX, we are just returning this and asserting the return type has a value of type XXX. The previous usage code would then be rendered as:

const registry0 = new Registry();

registry0.getWidget(WidgetType.chart); // error! 
registry0.getWidget(WidgetType.table); // error! 

const registry1 = registry0.addWidget(WidgetType.chart, ChartView, ChartModel);

registry1.getWidget(WidgetType.chart); // okay
registry1.getWidget(WidgetType.table); // error! registry doesn't have that

const registry = registry1.addWidget(WidgetType.table, TableView, TableModel);

registry.getWidget(WidgetType.chart); // okay
registry.getWidget(WidgetType.table); // okay

const chartWidgetFactory = registry.getWidget(WidgetType.chart);
const chartView = new chartWidgetFactory[0](); // ChartView
const chartModel = new chartWidgetFactory[1](); // ChartModel

const tableWidgetFactory = registry.getWidget(WidgetType.table);
const tableView = new tableWidgetFactory[0](); // TableView
const tableModel = new tableWidgetFactory[1](); // TableModel

function happiness() {
    registry.getWidget(WidgetType.chart); // okay
}

where you give names registry0 and registry1 to the intermediate registry objects. In practice you'd probably use method chaining and never give names to the intermediate things. Notice how the sadness() function is now happiness() because registry is always known to be fully configured and there's no control-flow analysis fiddliness to worry about.


Okay, hope that helps. Good luck!

Playground link to code

Upvotes: 1

Related Questions