Don't Call Overridables From Constructor in TypeScript


Published: 2018-05-19
Updated: 2018-05-20
Web: https://fritzthecat-blog.blogspot.com/2018/05/dont-call-overridables-from-constructor.html


Do not call overridable methods from constructor. This is a general risk-mitigation rule for most of today's object-oriented languages, among them Java and TypeScript. Not so widely known, but quite subtle and hard to understand.

In TypeScript, overridable methods are public or protected. In Java you could avoid the pitfall by making these methods final, but there is nothing like that in TypeScript. Now, what exactly is the pitfall?

Example

The problem occurs not always when you call an overridable method from constructor, just when that overridable uses instance fields of its own class that are expected to have been initialized to a certain value.

Following example classes should make this clear. They represent the idea of encapsulating a Name string. Let's assume that FirstName and LastName are both names, but with different semantics, one being a different default value. (I left out all other semantics, so the classes may not look really useful.)

Name.ts
 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export abstract class Name
{
private value: string;

constructor() {
this.value = this.getDefault();
}

/** Sub-classes must define a default. */
protected abstract getDefault(): string;

/** Expose the value readonly. */
public getValue(): string {
return this.value;
}
}

The abstract super-class Name leaves it up to sub-classes to define a default-value for the encapsulated value string. It does so by declaring a protected abstract getDefault() method. Mind that TypeScript, like Java, requires a class to be abstract when it contains an abstract method, and sub-classes are forced to implement it, or be abstract again.

FirstName.ts
 1
2
3
4
5
6
7
8
9
10
import { Name } from "./Name.js";

export class FirstName extends Name
{
public readonly defaultValue: string = "(No Firstname)";

protected getDefault(): string {
return this.defaultValue;
}
}

FirstName extends Name. The programmer decided to define the default in an instance field constant. Looks like nothing can break that code, right?

LastName.ts
Click to see LastName, which is exactly the same, just with a different default-name.
 1
2
3
4
5
6
7
8
9
10
import { Name } from "./Name.js";

export class LastName extends Name
{
public readonly defaultValue: string = "(No Lastname)";

protected getDefault(): string {
return this.defaultValue;
}
}

Test

Finally here is a test for these quite simple looking classes. You can execute it using the HTML page that I introduced in a recent Blog. Script references are on bottom of the page.

test.ts
 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { FirstName } from "./FirstName.js";
import { LastName } from "./LastName.js";

declare function title(testTitle: string): void;
declare function assert(criterion: boolean, message: string): void;

title("Don't Call Overridables From Constructor");

const firstName: FirstName = new FirstName();
assert(
firstName.getValue() === firstName.defaultValue,
"firstName.getValue() is expected to be '"+firstName.defaultValue+"': '"+firstName.getValue()+"'");

const lastName: LastName = new LastName();
assert(
lastName.getValue() === lastName.defaultValue,
"lastName.getValue() is expected to be '"+lastName.defaultValue+"': '"+lastName.getValue()+"'");

This first imports the classes to test. Then it declares two external functions that are provided by test.html (thus it depends on its test-executor). It outputs a title, and then constructs FirstName and LastName objects, executing the same assertion on both: check that getValue() returns the correct default value.

Mind that, due to the imports, all TS files must be in the same directory. You can compile them by:


tsc -t ES6 *.ts

Would you expect that the test succeeds?
When you load the test.html page into your browser, you will see this result:

Don't Call Overridables From Constructor

firstName.getValue() is expected to be '(No Firstname)': 'undefined'
lastName.getValue() is expected to be '(No Lastname)': 'undefined'

Both tests failed because the real value was undefined instead of the expected default name!

Executing an override before its owning object was initialized

The explanation of this pitfall is the object-initialization control flow.

Conclusion

There are several ways to fix this. One is to provide the default value as static field. Another one is to hardcode the default inside the getDefault() override implementation. The basic problem is the initialization order of object instances. Constructors are not really object-oriented, they are a legacy from structured languages. Whatever is inside a constructor is hardly overridable.

In my next Blog I will show how this can be fixed without static fields or hardcoding values inside methods.





ɔ⃝ Fritz Ritzberger, 2018-05-19