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).
No it will not create an objects of the base class, the inherited class's objects can have accessibility to the base class properties(according to the protection level). so that the particular members(available to the inherited class) are only initialized, no object of base class is created.
The TypeScript uses class inheritance through the extends keyword. TypeScript supports only single inheritance and multilevel inheritance.
Just like object-oriented languages such as Java and C#, TypeScript classes can be extended to create new classes with inheritance, using the keyword extends . In the above example, the Employee class extends the Person class using extends keyword.
TypeScript supports single inheritance and multilevel inheritance. We can not implement hybrid and multiple inheritances using TypeScript. The inheritance uses class-based inheritance and it can be implemented using extends keywords in typescript.
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
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
.
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With