如何让动态生成的 getter 和 setter 为 TypeScript 的编译器所知?

How can I make dynamically generated getter and setters known to TypeScript's compiler?

以下代码在 JavaScript 中没有任何错误:

class Test {
    constructor() {
        Object.defineProperty(this, "hello", {
            get() {return "world!"}
        })
    }
}

let greet = new Test()
console.log(greet.hello)

但是 TypeScript 抛出这个错误:

Property 'hello' does not exist on type 'Test'.

更新

这与我需要的和我正在尝试的类似。 Playground link。 我想在编译时检查属性,因为它们将来可能会改变。

class ColorPalette {
    #colors = [
        ["foregroundColor", "#cccccc"], 
        ["backgroundColor", "#333333"], 
        ["borderColor", "#aaaaaa"]
    ]; 

    constructor() {
        this.#colors.forEach((e, i) => {
            Object.defineProperty(this, this.#colors[i][0], {
                enumerable: true,
                get() { return this.#colors[i][1]; },
                set(hex: string) { 
                    if (/^#[0-9a-f]{6}$/i.test(hex)) { 
                        this.#colors[i][1] = hex;
                    }
                }
            })
        });
    }

    toString(): string {
        return Object.values(this).map(c => c.toString().toUpperCase()).join(", ");
    }
}
let redPalette = new ColorPalette();
// redPalette.foregroundColor = "#ff0000";  <----- error: "Property 'foregroundColor' does not exist on type 'ColorPalette'" 
console.log(redPalette.toString());

有两件事阻碍了您的代码按原样工作。

首先是您不能在 TypeScript 代码的 constructor 方法体中隐式声明 class 字段。如果你想让 class 有一个 属性,你需要在构造函数外显式声明这个 属性:

class ColorPalette {
    foregroundColor: string; // <-- must be here
    backgroundColor: string; // <-- must be here
    borderColor: string; // <-- must be here
// ...

microsoft/TypeScript#766 asking for such in-constructor declarations, and an open-but-inactive suggestion at microsoft/TypeScript#12613 上有一个被拒绝的建议要求相同,但在可预见的未来,它不是语言的一部分。

第二个问题是,在 TypeScript 代码中,在构造函数中调用 Object.defineProperty() 并不能使编译器相信所讨论的 属性 是 definitely assigned, so when you use the --strict compiler option, you'd need something like a definite assignment assertion 以消除警告:

class ColorPalette {
    foregroundColor!: string; // declared and asserted
    backgroundColor!: string; // ditto
    borderColor!: string; // ditto
// ...

microsoft/TypeScript#42919 上有一个建议,让编译器将 Object.defineProperty() 识别为初始化属性,但目前,它还不是语言的一部分。

如果您愿意将每个 属性 的名称和类型写两次,那么您可以让您的代码按照最初的方式工作。如果你不是,那么你需要做点别的事情。


一种可能的前进方式是制作一个 class 工厂函数 ,它根据一些输入生成 class 构造函数。您将一些 属性 描述符(嗯,return 此类描述符的函数)放入函数中,然后出现一个 class 构造函数来设置这些属性。它可能看起来像这样:

function ClassFromDescriptorFactories<T extends object>(descriptors: ThisType<T> &
    { [K in keyof T]: (this: T) => TypedPropertyDescriptor<T[K]> }): new () => T {
    return class {
        constructor() {
            let k: keyof T;
            for (k in descriptors) {
                Object.defineProperty(this, k, (descriptors as any)[k].call(this))
            }
        }
    } as any;
}

调用签名的意思是:对于任何泛型T对象类型,可以传入一个descriptors对象,其键来自T,其属性为零- arg 为来自 T 的每个 属性 生成 属性 描述符的函数;并且工厂函数的结果有一个 construct signature 生成类型 T.

的实例

ThisType<T> 不是必需的,但在您根据其他 class 属性定义描述符的情况下有助于推理。更多内容如下:

当调用构造函数时,实现遍历 descriptors 的所有键,并且对于每个这样的键 k,它在 this 上定义一个 属性密钥 k,以及调用 descriptors[k].

时出现的 属性 描述符

请注意,编译器无法验证实现是否与调用签名匹配,原因与它无法验证您的原始示例相同;我们还没有声明属性,也没有看到 Object.defineProperty() 初始化它们。这就是 returned class 已经 asserted as any 的原因。这会抑制有关 class 实现的任何警告,因此我们必须注意实现和调用签名匹配。

但是无论如何,一旦我们有了ClassFromDescriptorFactories(),我们就可以多次使用它。


对于您的调色板示例,您可以制作一个通用的 colorDescriptor() 函数,该函数采用 initValue 字符串输入,并生成一个无参数函数,该函数生成一个 属性 具有您想要的验证的描述符:

function colorDescriptor(initValue: string) {
    return () => {
        let value = initValue;
        return {
            enumerable: true,
            get() { return value },
            set(hex: string) {
                if (/^#[0-9a-f]{6}$/i.test(hex)) {
                    value = hex;
                }
            }
        }
    }
}

value变量用于存储颜色的实际字符串值。间接 () => {...} 的全部要点是最终 class 的每个实例都有其自己的 value 变量;否则,你最终会让 value 成为 class 的静态 属性,这是你不想要的。

现在我们可以将它与 ClassFromDescriptorFactories() 一起使用来定义 ColorPalette:

class ColorPalette extends ClassFromDescriptorFactories({
    foregroundColor: colorDescriptor("#cccccc"),
    backgroundColor: colorDescriptor("#333333"),
    borderColor: colorDescriptor("#aaaaaa"),
}) {
    toString(): string {
        return Object.values(this).map(c => c.toString().toUpperCase()).join(", ");
    }
}

编译没有错误,编译器将 ColorPalette 的实例识别为在键 foregroundColorbackgroundColor 和 [=50= 处具有 string 值的属性],并且在运行时这些属性具有适当的验证:

let redPalette = new ColorPalette();

redPalette.foregroundColor = "#ff0000";
console.log(redPalette.toString()); // "#FF0000, #333333, #AAAAAA" 

redPalette.backgroundColor = "oopsie";
console.log(redPalette.backgroundColor) // still #333333

为了确保每个实例都有自己的属性,让我们创建一个新实例:

let bluePalette = new ColorPalette();
bluePalette.foregroundColor = "#0000ff";
console.log(redPalette.foregroundColor) // #ff0000
console.log(bluePalette.foregroundColor) // #0000ff

是的,bluePaletteredPalette 不共享 foregroundColor 属性。看起来不错!


请注意,在这种情况下,ThisType<T> 代码会派上用场,我们添加了一个新的描述符,该描述符引用了 class 的其他属性:

class ColorPalette extends ClassFromDescriptorFactories({
    foregroundColor: colorDescriptor("#cccccc"),
    backgroundColor: colorDescriptor("#333333"),
    borderColor: colorDescriptor("#aaaaaa"),
    foregroundColorNumber() {
        const that = this;
        const descriptor = {
            get() {
                return Number.parseInt(that.foregroundColor.slice(1), 16);
            }
        }
        return descriptor;
    }
}) {
    toString(): string {
        return Object.values(this).map(c => c.toString().toUpperCase()).join(", ");
    }
}

这里编译器理解为foregroundColorNumber()是在T上定义了一个number属性,实现里面的this对应的是T,因此调用如下工作没有错误:

console.log("0x" + redPalette.foregroundColorNumber.toString(16)) // 0xff0000
console.log(redPalette.foregroundColor = "#000000")
console.log("0x" + redPalette.foregroundColorNumber.toString(16)) // 0x0

如果删除 ThisType<T>,您会看到一些错误。

Playground link to code