canecse
canecse

Reputation: 1902

How to dynamically set the properties of child class inside the generic parent class in Typescript

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

Answers (1)

jcalz
jcalz

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.

Playground link to code

Upvotes: 1

Related Questions