I am struggling.
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 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).
CodePudding user response:
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 currently at Stage 3 of the TC39 process and therefore very likely to be part of JavaScript in their current form, or close to it. 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.
CodePudding user response:
This is partial answer which I'll update as you provide more info. e.g. answers to questions under your question above.
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 declare a new options
field in the Wizard
class (which would have also hidden Human.options
if you had.
CodePudding user response:
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
.