打字稿:我怎样才能完成class(在运行时注册)的动态method/functions?

Typescript: how can I have completion for dynamics method/functions of a class (registered at runtime)?

我到处搜索但没有得到关于如何实现这种行为的线索。我是打字稿的新手,如果这是一个愚蠢的问题,请原谅我。

下面的代码是我正在寻找的一个非常简化的版本:

const library = { // could also be a class if necessary
    registerMethod(name: string, method: () => any): void {
        this[name] = method;
    }
}

library.registerMethod('couldBeWhatever', () => { }); // could be called any number of times with custom functions and names

我正在寻找一种方法(不一定是上面的方法)在启动时注册 class 或对象的方法,并允许完成这些动态函数。这在打字稿中可能吗?

我猜测“在 运行 时间”是指您希望编译器执行 control flow anlysis 以尝试通过检查调用 libray.registerMethod()。显然,对于任何仅在 运行 时才知道的内容,都无法在 IDE 中完成,例如,如果您的代码生成了带有 Math.random() 的随机方法名称字符串或被拉出来自用户输入或 API 响应或其他内容的方法名称。

如果是这样,那么您可能想使用 assertion functions. An assertion function narrows 它的一个参数的类型(您也可以使用断言 方法 来缩小参数的类型调用它的对象)。

通过缩小,意味着断言函数之后的表观类型必须是断言函数之前表观类型的子类型。例如,你不能让断言函数将 string 变成 number,但是你 可以 让它把 string | number 变成number。因此,使用控制流分析对 library 的突变建模的警告之一是它不能用于使其与原始类型不兼容。您的 registerMethod() 方法看起来会将新成员添加到 library,并且将成员添加到对象类型是一种缩小形式。但是,如果您需要一个 alterMethod(),也就是说,将已注册方法的类型从 () => string 更改为 () => number,您将无法执行此操作。

下面是 library 的示例实现,其中 registerMethod() 作为断言方法:

interface _Lib<T extends Record<keyof T, () => any>> {
    registerMethod<K extends string, M extends () => any>(
        name: K,
        method: K extends keyof T ? T[K] : M
    ): asserts this is Library<{
        [P in K | keyof T]: P extends K ? M : P extends keyof T ? T[P] : never
    }>
}

type Library<T extends Record<keyof T, () => any>> = T & _Lib<T>;

const library: Library<{}> = {
    registerMethod(this: Library<any>, name: string, method: () => any): void {
        this[name] = method;
    }
}

里面有很多内容,但基本思想是 libraryLibrary<{}> 开始,只有 registerMethod() 方法。当您在 Library<T> 上调用 registerMethod() 时,名称类型为 K 且方法类型为 M,编译器将 narrow Library<T> 变成 Library<T & Record<K, M>> 之类的东西。这意味着,除了 registerMethod()T 中的任何内容外,它现在在键 K 处还有一个成员,其类型为 M.


您可以测试它是否有效:

library.registerMethod('couldBeWhatever', () => "hello");

console.log(library.couldBeWhatever().toUpperCase()); // HELLO
library.somethingElse; // error, somethingElse does not exist

library.registerMethod('somethingElse', () => 123);

console.log(library.couldBeWhatever().toUpperCase()); // still HELLO
console.log(library.somethingElse().toFixed(2)); // "123.00"

万岁,成功了!不过,断言函数也有一些注意事项,这对您的用例可能重要,也可能不重要。


首先,为了使用断言函数,您需要它或您调用它的对象被手动注释而不是推断。这就是为什么我不得不将上面的 library 按字面注释为 Library<{}>。这是目前 TypeScript 的设计限制。有关详细信息,请参阅 microsoft/TypeScript#36931 等。


接下来,与 all TypeScript 中的控制流分析缩小一样,编译器并非无所不知。它不会通过模拟 运行 程序的所有可能方式并查看哪些可能的类型缩小在所有范围内保持正确来执行控制流分析。这样做的成本会高得令人望而却步。现在,编译器所做的是:当您跨越函数边界时,编译器会重置所有控制流变窄。这是一个合理的权衡,因为编译器通常无法确定函数体何时会针对函数体外部的代码被调用,反之亦然。有关此问题的讨论,请参阅 microsoft/TypeScript#9998

以上代码的含义:当您使用 library 注册方法时,您将能够在相同的函数范围内使用它们。但是你不能在函数体或其他模块等任意其他范围内“之后”使用它们:

library.registerMethod('somethingElse', () => 123);
function oops() {
    library.somethingElse() // the compiler doesn't know about this
}

编译器真的不知道在调用 oops() 时,somethingElse 已经在 library 上注册了。实际上,如果不检查程序中各处的所有代码,我也不知道。

一个解决方法是在某处完成所有注册,然后通过将生成的库“保存”到新的 const 变量中来“冻结”生成的库的类型。

const registeredLibrary = library;
function okay() {
    registeredLibrary.somethingElse(); // the compiler does know about this
}

之所以有效,是因为 registeredLibrary 是作为 library 的副本创建的,其范围内的方法已经在其上注册。代码中没有任何地方 registeredLibrary 没有这两个额外的方法,因此编译器很乐意在 okay() 函数体内使用它们。


很可能上述警告不适合您。编译器非常强大,但无法处理代码可以运行 以所有可能方式在任何地方同时发生的分析。但是断言方法至少可以在一定程度上对这种“动态”行为进行建模。

Playground link to code