Home > database >  How to setup Typescript inherited classes where subclasses have different properties?
How to setup Typescript inherited classes where subclasses have different properties?

Time:11-02

I'm new to Typescript and trying to wrap my head around the best way to handle class inheritance where base classes have some different properties.

Basically, I have a base class for which I want to define some common functionality and a set of subclasses that have different properties as they represent different database models. I'm trying to figure out how to get the types working.

For example:

class BaseClass {
  static create(props) { /*... */ }
  update(props) { /*... */ }
}

type SubClassOneProps = {
  firstName: string
  lastName: string
}
class SubClassOne extends BaseClass {
  firstName!: string
  lastName!: string
}

type SubClassTwoProps = {
  streetName: string
  streetNumber: number
}
class SubClassTwo extends BaseClass {
  streetName!: string
  streetNumber!: number
}

// I'm looking for typing that will allow me to do the following:
SubClassOne.create({firstName: "Bob", lastName: "Doe"})
SubClassTwo.create({streetName: "Sunset Blvd", streetNumber: 100})

//and then same idea with the instance methods, although I would use Partial<> with these

Since the properties are different for each subclass, the signatures vary a bit even though they will all be basic key/value pairs. I don't see how to get the typing right and can't figure out how to specify the properties from the subclasses.

I'm also going to need to store some metadata on each of these properties (specifically, whether they should be publicly accessible or not), and then have an instance method that can export the public properties to a JSON object. But I'll save that as another problem for later.

Any guidance appreciated!

CodePudding user response:

I don't believe there's a more clean way for a static function than passing a generic to it e.g

class BaseClass {
  static create<T>(props: T) {
    /*... */
  }
  update(props) {
    /*... */
  }
}

type SubClassOneProps = {
  firstName: string;
  lastName: string;
};
class SubClassOne extends BaseClass {
  firstName!: string;
  lastName!: string;
}

type SubClassTwoProps = {
  streetName: string;
  streetNumber: number;
};
class SubClassTwo extends BaseClass {
  streetName!: string;
  streetNumber!: number;
}

SubClassOne.create<SubClassOneProps>({ firstName: 'Bob', lastName: 'Doe' });
SubClassTwo.create<SubClassTwoProps>({ streetName: 'Sunset Blvd', streetNumber: 100 });

In this scenario T is a generic and it's being used as the type of props.

When we're calling SubClassSmth.create({}) we pass in the generic.

If the function wasn't static it could be done in a way cleaner manner e.g assuming create is not static than we could pass in the generic at class level and you wouldn't have to write it again.

class BaseClass<U> {
  create(props: U) {
    /*... */
  }
  update(props) {
    /*... */
  }
}

type SubClassOneProps = {
  firstName: string;
  lastName: string;
};
class SubClassOne extends BaseClass<SubClassOneProps> {
  firstName!: string;
  lastName!: string;
}

type SubClassTwoProps = {
  streetName: string;
  streetNumber: number;
};
class SubClassTwo extends BaseClass<SubClassTwoProps> {
  streetName!: string;
  streetNumber!: number;
}

const subClassOneFactory = new SubClassOne();
const subClassTwoFactory = new SubClassTwo();


subClassOneFactory.create({ firstName: 'Bob', lastName: 'Doe' });
subClassTwoFactory.create({ streetName: 'Sunset Blvd', streetNumber: 100 });

Additional info on why we can't use class-level generics on static functions - Calling a static function on a generic class in TypeScript

CodePudding user response:

The main stumbling block here is the lack of polymorphic this types on static class members. See microsoft/TypeScript#5863 for more information.

For instance members, you can use the type named this to refer to the "current" subclass:

class BaseClass {
  declare test: Array<this>;
}

class SubClassOne extends BaseClass { /* snip */ }
const sc1 = new SubClassOne();
sc1.test // SubClassOne[]

class SubClassTwo extends BaseClass { /* snip */ }
const sc2 = new SubClassTwo();
sc2.test // SubClassTwo[]

But there is currently no analog for static members. For static methods, you can use the workaround of making the method generic and tying the method's this context to the generic type parameter via a this parameter:

class BaseClass {
  declare static test: <T extends BaseClass>(this: new () => T) => Array<T>;
}

class SubClassOne extends BaseClass { /* snip */ }
const sc1t = SubClassOne.test();
// const sc1t: SubClassOne[]

class SubClassTwo extends BaseClass { /* snip */ }
const sc2t = SubClassTwo.test();
// const sc1t: SubClassTwo[]

In the above, we're saying that test() must be called on a constructible object of type new () => T. When you call SubClassOne.test(), the compiler tries to infer T from typeof SubClassOne being of type new () => T, and so T is inferred as the instance type, SubClassOne. That's a roundabout way of getting a this type from a constructor, but you can do it.


So now we have to decide how to associate each subclass with its "props" type, since apparently we need to do that. I propose making the base class a generic class where the type parameter T corresponds to the props type:

class BaseClass<T extends object> {
  update(props: T) { Object.assign(this, props) }
}

class SubClassOne extends BaseClass<SubClassOneProps> {
  firstName!: string
  lastName!: string
}
const sc1 = new SubClassOne();
sc1.update({ firstName: "Bob", lastName: "Doe" });

class SubClassTwo extends BaseClass<SubClassTwoProps> {
  streetName!: string
  streetNumber!: number
}
const sc2 = new SubClassTwo();
sc2.update({ streetName: "Sunset Blvd", streetNumber: 100 });

Now we can finally declare create():

class BaseClass<T extends object> {
  static create<T extends BaseClass<any>>(
    this: { new(): T },
    props: T extends BaseClass<infer U> ? U : never
  ) { const ret = new this(); ret.update(props); return ret; }
}

So the this context of create() has to be a zero-arg constructor producing an instance of generic T, and we use conditional type inference to infer the props type from it. That is, if T is BaseClass<X> for some X, then T extends BaseClass<infer U> ? U : never evaluates to X.

Let's test it out:

const sc1 = SubClassOne.create({ firstName: "Bob", lastName: "Doe" });
// sc1: SubClassOne
console.log(sc1.firstName.toUpperCase()) // "BOB"

const sc2 = SubClassTwo.create({ streetName: "Sunset Blvd", streetNumber: 100 });
// sc2: SubClassTwo
console.log(sc2.streetNumber.toFixed(2)) // "100.00"

Looks good!

Playground link to code

  • Related