打字稿中的类型安全字典



在Typescript 中是否有可能让我可以根据WidgetType参数返回某种类型?例如 - 如果我指定WidgetType.chartwidgetFactory分配类型[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]();
}

首先,TypeScript 的类型系统是结构化的,而不是名义上的,所以如果你想让编译器可靠地区分两种类型,它们应该有不同的结构。 在示例代码中,人们倾向于编写空类型,如class Foo {}class Bar {},但编译器将它们视为同一类型,尽管名称不同。 解决方案是向每种类型添加一个虚拟属性,以使它们不兼容。 所以我要这样做:

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()方法返回新的注册表对象,并要求您使用方法链接而不是重用原始注册表对象,如下所示:

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

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

但是您希望看到的是,每次对Registry对象调用addWidget()时,编译器都会改变对象的类型,以说明特定类型的小部件具有与之关联的特定模型和视图类型这一事实。 TypeScript 不支持任意改变现有值的类型。 它确实支持通过控制流分析暂时缩小值的类型,但在 TypeScript 3.7 之前,没有办法说像addWidget()这样的方法应该导致这种缩小。


TypeScript 3.7 引入了断言函数,它允许你说一个 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];
}
}

在这里,我在单个类型参数DRegistry泛型,它表示将WidgetType枚举的子集映射到一对视图和模型类型的字典。 这将默认为空对象类型{},因此类型Registry的值表示尚未添加任何内容的注册表。

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

addWidget()方法是一种通用断言方法,它采用W类型的枚举、V类型的视图构造函数和M类型的模型构造函数,并通过向D添加属性来缩小this的类型。将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()上出错。 这是一个痛点,我不知道它是否会或何时会好转。

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

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 的快速信息,您可以看到其类型从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;
}

区别:我们只是返回this,而不是asserts this is XXX,并断言返回类型的值为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始终已知已完全配置,并且无需担心控制流分析的繁琐性。


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

操场链接到代码

使用类型谓词的可区分联合加上类型保护是您想要的:

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

最新更新