打字稿中的类型安全字典

时间:2020-04-13 09:27:10

标签: typescript

在Typescript中是否有可能使我能够根据WidgetType参数返回某个类型?例如-如果我指定WidgetType.chart,是否为widgetFactory分配了类型[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]();
    }

2 个答案:

答案 0 :(得分:1)

首先,TypeScript的类型系统是structural,而不是名义上的,因此,如果您希望编译器可靠地区分两种类型,则它们应具有不同的结构。在示例代码中,尽管名称不同,但人们倾向于写空类型,例如class Foo {}class Bar {},但the compiler sees those as the same type。解决方案是向每种类型添加一个虚拟属性,以使它们不兼容。所以我要这样做:

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 }

为避免此处出现任何可能的问题。


对于3.7之前的TypeScript版本,我的建议是:

  • 一次构建所有注册表,例如拥有一个构造函数,要求您在开始时将所有内容都放入其中,例如:

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

    或:

  • 具有addWidget()方法返回一个新的注册表对象,并要求您使用method chaining而不是重新使用原始的注册表对象,例如:

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

这些解决方案的优点是允许编译器为每个值赋予一个不变的类型。在第一个建议中,只有完整配置的registry对象。在第二个建议中,在配置的各个阶段中都有多个注册表对象,但是您只使用最后一个。无论哪种方式,类型分析都非常简单。

但是您想看到的是,每次您在addWidget()对象上调用Registry时,编译器 mutant 都会改变对象的类型,以说明以下事实:特定类型的小部件具有与之关联的特定模型和视图类型。 TypeScript不支持任意更改现有值的类型。它确实通过control flow analysis支持对值类型的临时 narrowing ,但是在TypeScript 3.7之前,还没有办法说像addWidget()这样的方法。应该会缩小范围。


TypeScript 3.7引入了assertion functions,它使您可以说一个返回void的函数应该触发该函数的参数之一或要在其上调用方法的对象的特定控制流范围(如果函数是一种方法)。其语法是使方法的返回类型看起来像asserts this is ...

这是为Registry键入内容以使用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];
    }
}

在这里,我在单个类型参数Registry中使D通用,它表示将WidgetType枚举的子集映射到一对视图和模型类型的字典。这将默认为空对象类型{},因此类型Registry的值表示没有添加任何注册表。

_dict属性是mapped type的属性,它采用D,并使其元组中的属性为零参数。请注意,编译器无法知道{}是该类型的有效值,因为它不知道在构造Registry对象时,它始终具有D的值。 {},因此我们需要类型断言。

addWidget()方法是一种通用的断言方法,它采用W类型的枚举,V类型的视图构造函数和M类型的模型构造函数,以及通过向this添加属性来缩小D的类型...将D更改为D & { [K in W]: { v: V, m: M } }

最后,getWidget()方法仅允许您指定作为D键的枚举类型。通过这样做,我们可以使字典的属性成为必需属性而不是可选属性,并且getWidget()将永远不会返回undefined。相反,将不允许您使用尚未添加的枚举来调用getWidget()


让我们看看它的实际效果。首先,我们创建一个新的Registry

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

这是使用断言函数作为方法的重大警告之一:仅当对象属于明确注释的类型时,它们才起作用。有关更多信息,请参见microsoft/TypeScript#33622。如果您写的const registry = new Registry()没有注释,则会在addWidget()上出现错误。这是a pain point,我不知道它何时会变得更好。

有了我们的不愉快,让我们继续前进:

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

这些是错误的,因为注册表还没有小部件。

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

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

现在,您可以获得图表小部件,但没有表格小部件。

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

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

现在您可以押注两个小部件了。对addWidget()的每次调用都缩小了registry的类型。如果将鼠标悬停在registry上并查看IntelliSense中的quickinfo,您会看到其类型从Registry<{}>Registry<{0: {v: ChartView; m: ChartModel;};}>Registry<{0: {v: ChartView; m: ChartModel;};} & {1: {v: TableView; m: TableModel;};}>

的演变。

现在我们可以使用getWidget()方法来获取强类型的视图和模型:

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

万岁!


所有工作正常。断言方法仍然是脆弱的。您需要在某些地方使用显式注释。而且所有控制流分析的范围都是暂时的,并且不会持续到封闭状态(请参见microsoft/TypeScript#9998):

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

编译器不知道,也无法弄清楚在调用sadness()registry将被完全配置。因此,我仍然可能会推荐一种原始的解决方案。为了完整起见,我将向您展示方法链接解决方案的外观:

    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;
    }

区别:我们只是返回asserts this is XXX而不是this,并且断言返回类型的值是类型XXX。先前的用法代码将呈现为:

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
}

在其中将名称registry0registry1赋予中间注册表对象。在实践中,您可能会使用方法链接,并且永远不要给中间的事物命名。请注意,sadness()函数现在是happiness()的原因,因为众所周知registry已被完全配置,并且没有控制流分析的麻烦。


好的,希望能有所帮助。祝你好运!

Playground link to code

答案 1 :(得分:0)

Discriminated unions 加上使用 type predicates 的类型保护是您想要的:

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)
}