a5hk
a5hk

Reputation: 7834

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

The following code works without any errors in JavaScript:

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

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

But TypeScript throws this error:

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

Update

This is similar to what I need and I was trying. Playground link. I want compile time check for properties becasue they might change in future.

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());

Upvotes: 3

Views: 1643

Answers (1)

jcalz
jcalz

Reputation: 328387

There are two things standing in the way of your code working as-is.

The first is that you cannot declare class fields implicitly inside the constructor method body in TypeScript code. If you want a class to have a property, you will need to explicitly declare this property outside the constructor:

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

There's a declined suggestion at microsoft/TypeScript#766 asking for such in-constructor declarations, and an open-but-inactive suggestion at microsoft/TypeScript#12613 asking for the same, but for the foreseeable future, it's not part of the language.

The second problem is that, in TypeScript code, calling Object.defineProperty() in a constructor does not convince the compiler that the property in question is definitely assigned, so when you use the --strict compiler option, you'd need something like a definite assignment assertion to quiet the warnings:

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

There's a suggestion at microsoft/TypeScript#42919 for the compiler to recognize Object.defineProperty() as initializing properties, but for now, it's also not part of the language.

If you're willing to write the name and type of each property twice, then you can get your code to work way you originally had it. If you aren't, then you need to do something else.


One possible way forward is to make a class factory function that produces class constructors from some input. You put some property descriptors (well, functions that return such descriptors) into the function, and out comes a class constructor which sets those properties. It could look like this:

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

The call signature means: for any generic T of an object type, you can pass in a descriptors object whose keys are from T, and whose properties are zero-arg functions that produce property descriptors for each property from T; and what comes out of the factory function has a construct signature that produces instances of type T.

The ThisType<T> is not necessary, but helps with inference in the case where you are defining descriptors in terms of other class properties. More below:

The implementation iterates through all the keys of descriptors when the constructor is called, and for each such key k, it defines a property on this with the key k, and the property descriptor that comes out when you call descriptors[k].

Note that the compiler cannot verify that the implementation matches the call signature, for the same reasons that it cannot verify your original example; we have not declared the properties and Object.defineProperty() has not been seen to initialize them. That's why the returned class has been asserted as any. This suppresses any warnings about the class implementation, so we have to be careful that the implementation and call signature match.

But anyway, once we have ClassFromDescriptorFactories(), we can use it multiple times.


For your color palette example, you can make a general-purpose colorDescriptor() function which takes an initValue string input, and which produces a no-arg function which produces a property descriptor with the validation you want:

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

The value variable is used to store the actual string value of the color. The whole point of the indirection () => {...} is so that each instance of the eventual class has its own value variable; Otherwise, you'd end up having value be a static property of the class, which you don't want.

And now we can use it with ClassFromDescriptorFactories() to define 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(", ");
    }
}

This compiles without error, and the compiler recognizes instances of ColorPalette as having string-valued properties at keys foregroundColor, backgroundColor, and borderColor, and at runtime these properties have the proper validation:

let redPalette = new ColorPalette();

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

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

And just to make sure each instance has its own properties, let's create a new instance:

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

Yeah, bluePalette and redPalette don't share a common foregroundColor property. Looks good!


Note the ThisType<T> code comes in handy in situations like this, where we are adding a new descriptor that refers to other properties of the 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(", ");
    }
}

Here the compiler understands that foregroundColorNumber() is defining a number property on T, and that the this inside the implementation corresponds to T, and thus calls like the following work without error:

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

If you remove ThisType<T>, you will see some errors show up.

Playground link to code

Upvotes: 4

Related Questions