Reputation: 7834
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
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.
Upvotes: 4