Reputation: 1902
Suppose I have an abstract class that has the method setPropertiesToTrue that takes in a list of class properties from a child class, and sets those properties inside the child to true.
abstract class BaseObject<TBooleanProp extends string>{
setPropertiesToTrue(properties: TBooleanProp[]){
properties.forEach(property => {
(this[property] as boolean) = true
})
}
}
class SomeObject extends BaseObject<"x"| "y">{
x: boolean ;
y: boolean;
z: string;
constructor(x: boolean, y: boolean, z: string){
super();
this.x = x;
this.y = y;
this.z = z;
}
}
const object = new SomeObject(false, false, "hello")
object.setPropertiesToTrue(["x", "y"])
// @ts-expect-error this should not be possible
object.setPropertiesToTrue(["z"])
console.log(object.x) // changed from false to true
console.log(object.y) // changed from false to true
Here is a TS Playground example of more or less what I'm trying to do.
In the example above, I have no idea how to tell Typescript that the properties being passed into setPropertiesToTrue
are boolean properties setup in the child constructor.
Hence, typescript is rightfully complaining at line 4. Is there a way to get around this?
Upvotes: 1
Views: 1556
Reputation: 329378
In TypeScript, class
and interface
declarations must have statically known keys. This means that the compiler must know their exact literal keys at the declaration site. There are ways to represent types with dynamic/programmatic keys, such as in mapped types like type Foo<K extends string> = {[P in K]: number}
. The Record<K, V>
utility type is probably the canonical example of a mapped type with a dynamic key.
So such types can be described and used, but the compiler will never allow or accept a class
declaration to be of those types directly. Instead, you can make a class
declaration that acts the way you want at runtime and give it whatever types the compiler needs to make it compile. After this you use a type assertion to say that that class is of the dynamic type you want.
Here's one way to do it:
type BaseObject<K extends string> = Record<K, boolean> & {
setPropertiesToTrue(properties: K[]): void;
}
const BaseObject = class BaseObject {
[k: string]: any;
setPropertiesToTrue(properties: string[]) {
properties.forEach(property => {
this[property] = true
})
}
} as any as abstract new <K extends string>() => BaseObject<K>;
So, the type BaseObject<K>
describes the shape of an instance of the BaseObject
class. It is the intersection of Record<K, boolean>
(a type with keys in K
whose values are boolean
) and {setPropertiesToTrue(properties: K[]): void}
(a type with a single method accepting an array of K
elements).
We assign a class
expression to BaseObject
which acts the way we want at runtime. By giving it a string index signature whose value is of the any
type, we've mostly turned off type checking inside the class body.
And finally, we assert that this class expression is of the type abstract new <K extends string>() => BaseObject<K>
; that's an abstract
construct signature which says that BaseObject
is an abstract class constructor (so if you try to write new BaseObject()
directly you'll get an error). Without abstract
it would just be a construct signature.
It's also a generic constructor, so when you use the new
operator, you specify a type parameter K
and the resulting instance is of type BaseObject<K>
.
This needs to be an assertion because the compiler can't see the class expression as meeting that dynamic thing. It's unrelated enough according to the compiler that you need to use as any as
or as unknown as
to suppress the error.
Let's see if it works.
class SomeObject extends BaseObject<"x" | "y"> {
z: string;
constructor(x: boolean, y: boolean, z: string) {
super();
this.x = x;
this.y = y;
this.z = z;
}
}
const object = new SomeObject(false, false, "hello")
object.setPropertiesToTrue(["x", "y"]) // okay
object.setPropertiesToTrue(["z"]) // error
// -----------------------> ~~~
// Type '"z"' is not assignable to type '"x" | "y"'.
Looks good! The compiler sees object
as having properties x
, y
, and z
, and only lets you call setPropertiesToTrue()
with arrays containing "x"
and "y"
; note that in SomeObject
I didn't have to explicitly declare the x
and y
properties; these are inherited from BaseObject<"x" | "y">
, which is good, since that's part of the point of BaseObject
.
Upvotes: 1