Typescript Inheritance: Expanding base class object property
Asked Answered
S

3

24

When extending a class, I can easily add some new properties to it.

But what if, when I extend a base class, I want to add new properties to an object (a property which is a simple object) of the base class?

Here is an example with some code.

base class

type HumanOptions = {
  alive: boolean
  age: number
}

class Human {
  id: string
  options: HumanOptions

  constructor() {
    this.id = '_' + Math.random()

    this.options = {
      alive: true,
      age: 25,
    }

  }
}

derived class

type WizardOptions = {
  hat: string
} & HumanOptions

class Wizard extends Human{
 
  manaLevel: number
  options: WizardOptions // ! Property 'options' has no initializer and is not definitely assigned in the constructor.

  constructor() {
    super()
    this.manaLevel = 100
    this.options.hat = "pointy" // ! Property 'options' is used before being assigned.
  }
}

Now, as you can see from the in-line comments, this will anger TypeScript. But this works in JavaScript. So what is the correct ts way to achive this? If there is not, is the problem in my code-pattern itself? What pattern would be suited for the problem?

Maybe you are wondering why I want those properties in a options object. It can be very useful! For example: only the properties in the options are be edited through some UI. So, some other code could dynamically look for the options in the class and expose/update them on the UI, meanwhile properties like id and manaLevel would be left alone.

Note

I know I could do this for the wizard:

class Wizard extends Human{
 
  manaLevel: number

  options: {
    hat: string
    alive: boolean
    age: number
  }

  constructor() {
    super()
    this.manaLevel = 100
    this.options = {
      alive: true,
      age: 25,
      hat: "pointy"
    }
  }
}

And it works. but then the code is not very DRY and I have to manually manage the options inheritance (which in a complex application is not ideal).

Scent answered 6/12, 2021 at 18:21 Comment(2)
Clorofilla, when you said "But this works in JavaScript", did you mean that it works at runtime in the JavaScript which was output by the TypeScript compiler? Or did you mean that you wrote similar JavaScript by hand and it worked there? There's a but of a disagreement over the meaning of that in a comment chain.Drivein
I meant the first case. TS complains but the JS output runs without errors.Scent
D
21

If you're just going to reuse a property from a superclass but treat it as a narrower type, you should probably use the declare property modifier in the subclass instead of re-declaring the field:

class Wizard extends Human {
  manaLevel: number
  declare options: WizardOptions; // <-- declare
  constructor() {
    super()
    this.manaLevel = 100
    this.options.hat = "pointy" // <-- no problem now
  }
}

By using declare here you suppress any JavaScript output for that line. All it does is tell the TypeScript compiler that in Wizard, the options property will be treated as a WizardOptions instead of just a HumanOptions.


Public class fields are at Stage 4 of the TC39 process and will be part of ES2022 (currently in draft at 2021-12-07). When that happens (or maybe now if you have a new enough JS engine), the code you wrote will end up producing JavaScript that looks like

class Wizard extends Human {
    manaLevel;
    options; // <-- this will be here in JavaScript
    constructor() {
        super();
        this.manaLevel = 100;
        this.options.hat = "pointy";
    }
}

which is just your TypeScript without type annotations. But that options declaration in JavaScript will initialize options in the subclass to undefined. And thus it really will be true that this.options is used before being assigned:

console.log(new Wizard()); // runtime error!
// this.options is undefined

So the error you're getting from TypeScript is a good one.


Originally TypeScript implemented class fields so that just declaring a field wouldn't have an effect if you didn't initialize it explicitly. That was how the TypeScript team thought class fields would eventually be implemented in JavaScript. But they were wrong about that. If your compiler options don't enable --useDefineForClassFields (and notice that if you make --target new enough it will be automatically included; see microsoft/TypeScript#45653) then it will output JS code where no runtime error occurs for your example.

So you could argue that the compiler should not give you an error if you are not using --useDefineForClassFields. Before --useDefineForClassFields existed, such an argument was actually made at microsoft/TypeScript#20911 and microsoft/TypeScript#21175.

But I don't think the TS team is too receptive to this anymore. As time goes on, declared class fields in JavaScript being initialized to undefined will be the norm and not the exception, and supporting the older nonconforming version of class fields isn't anyone's priority. See microsoft/TypeScript#35831 where the suggestion is basically "use declare". Which is what I'm suggesting here too.

Playground link to code

Drivein answered 6/12, 2021 at 19:5 Comment(5)
Just noting that class fields are at stage 4, hence finalized, although the new spec has not been published yet.Motorbike
Okay I’ll edit when I get a chance.Drivein
should i move my answer into a preface of yours? See @TheRubberDuck's comment: #70250595Quickman
It's a different approach so it should probably stay as its own answer.Drivein
ok, i just referenced your answer in mine.Quickman
U
9

You can do it in a following way: playground

type HumanOptions = {
  alive: boolean
  age: number
}

class Human<Options extends HumanOptions> {
  id: string
  options: Options

  constructor() {
    this.id = '_' + Math.random()

    this.options = {
      alive: true,
      age: 25,
    } as Options
  }
}

type WizardOptions = {
  hat: string
} & HumanOptions

class Wizard extends Human<WizardOptions> {
  manaLevel: number

  constructor() {
    super()
    this.manaLevel = 100
    this.options.hat = "pointy"
  }
}

Note, that you are fully responsible on setting all required props of options.

Underpants answered 6/12, 2021 at 19:45 Comment(0)
Q
1

Your second error is results from the options field you declare in Wizard hiding the options field you declared in Human. This is how most (all) class-based type systems work, not just TS. Thus Wizard.options is undefined at the point this.options.hat = "pointy" executes. You can see this by commenting out options: WizardOptions

This worked in Javascript because you relied on prototypical inheritance, and didn't define a new options field in the Wizard class (which would have also hidden Human.options if you had.

Please see @jcalz's answer for what I think is the best solution for this case.

Quickman answered 6/12, 2021 at 19:7 Comment(7)
I think this would be a better answer than the accepted answer if you explained how to keep the Wizard-specific options property without hiding the Human version. Although I realize that means duplicating some of the accepted answer, I think your explanation is more direct and concise.Myra
Thank you! I didn't follow up with a complete explanation because the accepted answer beat me to it, and frankly I would have come up with a different solution because I did not know about declare! Maybe I should move my explanation into the accepted answer?Quickman
When you say "Wizard.options is undefined at the point this.options.hat = "pointy" executes", this is only true if --useDefineForClassFields is enabled and [[Define]] semantics are used instead of [[Set]] semantics. Eventually it might as well be true since the number of people using old ES targets or intentionally disabling --useDefineForClassFields will presumably dwindle. But for now this very much depends on your compiler options.Drivein
@Drivein But it was certainly true for the Clorofilla's case, given her error messages. Also, isn't --useDefineForClassFields the default for Typescript now?Quickman
No, it wasn't, they said "But this works in JavaScript" presumably because the compiled JS was not using --useDefineForClassFields. The default for --useDefineForClassFields is disabled unless you're targeting ESNext I think (maybe ES2022? not sure). So most people who write TS will currently see the behavior where the compiled JS works but the TS still has an error. If you read the part of my answer starting "So you could argue..." then you'll see the github issues where people complained about this.Drivein
@Drivein I took "But this works in JavaScript" as "it works for me when I do a similar thing in JS", not "But this works in the output JavaScript". I think they would have said that. Anyway, SO users have a ton of clarification now! 😉Quickman
Thanks for all the insightful clarifications Inigo and @jcalz. Yes, I did meant that if I ignored the TS error, then the compiled JS would execute without any error. Sorry, poor wording on my part.Scent

© 2022 - 2024 — McMap. All rights reserved.