Always allow code before super call when it does not use "this" #8277
Comments
Yeah, this an interesting issue because I personally have had to write special cases where you have to pass closures to the super to ensure they get executed before other blocks of code. On the other side of the coin, this is also an issue in C# as well because calling :base() you need to follow a similar pattern and have an onConstruct override or something like that. Or... all your properties need to be lazy.. :/ |
I also have many cases where I check / initialise local variables before calling super. It's great that TypeScript uses the best from other well-typed languages, but the way it is right now is simply out of line with common sense. Like per @jbaron example: constructor(id:String) {
var label = I18N.translate(id);
var icon = IconMap.get(id);
super(label, icon);
this.setBackground(this.color);
}
// vs…
constructor(id:String) {
super(I18N.translate(id), IconMap.get(id));
this.setBackground(this.color);
} That limitation doesn't bring in any value. There was an argument on complexity of the checks – doing check for |
It pretty much looks like this is fixed for 2.0 beta. Can you give it a try @pdfernhout? |
@DanielRosenwasser It's probably all right now. The error message explains what went wrong precisely I think. This is fine:
This is not:
|
Could someone tell me why this code is not permitted ? `export class MatterAccessRevokedForUser extends MatterUserDomainEvent {
}` |
This example clearly shows the need to allow code before super call when it does not use "this" to apply transformations to the parameters of super() Error: constructor(opts: ReadableOptions) {
opts.objectMode = true
super(opts)
} My solution constructor(opts: ReadableOptions) {
super((() => {
opts.objectMode = true
return opts
})())
} |
Is there any reason why having variable initiators on private variables in the sub class disables the above functionality?
Moving the variable initialisation so it's after the super call fixes it - but I'm curious as to the reasoning as by definition private variables should not have any side effects on the base class? |
@davidglezz this is exactly what I've had to do. |
@DanielRosenwasser I confirmed things works better under TypeScript 2.6. I did not test earlier 2.x versions. The Handbook page on Classes also reflects this improvement. Thanks to everyone who worked on those improvements. There are still edge cases that could be improved further like @marlon-tucker and @captainjono raised -- but as far as I am concerned the fix so far is good enough. === More details Below is the test example I used -- where The third commented code statement contains a direct access to The first two commented code items are for an initialized property and a parameter property. They are both an indirect access to An edge case involving conditional code with a presumably analyzable bifurcation in the code path to call Further improvements in those directions might be nice -- but such work is at risk of producing diminishing returns after the previous improvement. So, it is OK with me to close this issue and suggest people open a new issue if they want to continue discussing relaxing constraints further in such cases.
|
@mhegazy hi |
Fixes microsoft#8277. It feels wrong to put a new `forEachChild` loop in the checker, though in the vast majority of user files this will be a very quick one. Is there a better way to check for a reference to `super` or `this`?
The implementation details in #29374 are starting to reach into the transformer realm, so a summary of the findings (thanks @ajafff for pointing much of these out!):
The second point means this now requires some more changing to the checker and transformer. I think it's still doable if the original "you must call super immediately" requirement becomes "you must call a super before the end of the function or references to this or super" using control flow analysis. In other words, >=1 class Test extends Base {
prop = 1;
// Not acceptable, as the `super(...)` might not be reached
constructor() {
if (Math.random() > .5) super(1);
}
} class Test extends Base {
prop = 1;
// Acceptable, as there's no way for a `super(...)` not to be reached
constructor() {
if (Math.random() > .5) { super(1); }
else { super(0); }
}
} On top of that, Related but not the same: #23422 on the subject of multiple |
Because of this limitation, we can write let instance: Foo | null = null
class Foo {
static singleton() {
return new Foo()
}
private constructor() {
if (instance) return instance
instance = this
}
} but we can write this: let instance: Foo | null = null
class Foo extends SomeOtherClass {
static singleton() {
return new Foo()
}
private constructor() {
if (instance) return instance
super()
instance = this
}
} We can change private constructor() {
super() // unnecessary call.
if (instance) return instance
instance = this
} Why not at the very least allow expressions-with-no-this before That'd probably allow the vast majority of use cases to work. |
This just bit me in the foot. I ran into a case where the It'd be great to allow at least simple expressions before |
There seems to be inconsistency between how TypeScript handles properties. Consider this code: // TypeScript
class Foo {}
class Bar extends Foo {
prop = 1
constructor() {
console.log("foo") // <-- A 'super' call must be the first statement in the constructor when a class contains initialized properties or has parameter properties.(2376)
super()
}
} TypeScript transforms the code to this: // TypeScript transformed JS
class Foo {}
class Bar extends Foo {
constructor() {
this.prop = 1; // <-- TypeScript puts the initializer before super, hence the error
console.log("foo");
super();
}
} While Babel does this: // Babel transformed JS
class Foo {}
class Bar extends Foo {
constructor() {
console.log("foo");
super();
_defineProperty(this, "prop", []); // <-- Babel puts the initializer after super
}
} Oddly enough if you take out the code before super, TypeScript then decides to do the initialization after super: // TypeScript
class Foo {}
class Bar extends Foo {
prop = 1
constructor() {
super()
}
} Gets transformed to: // TypeScript transformed JS
class Foo {}
class Bar extends Foo {
constructor() {
super();
this.prop = 1;
}
} Babel transforms the code the same way no matter if code is put before super or not. It allows code to be placed before super as long as it doesn't access It appears that TypeScript also allows this, as long as you don't initialize the property inline. If you do the initialization manually inside the constructor it works as expected. IMO, this is a bug. Property initialization should be done the same way regardless if code is placed before super or not. Babel does this correctly while TypeScript currently does not. |
I agree with this! Recently I have come upon this issue multiple times in my code base:
Being able to do this instead would be so nice:
|
@piotrgajow Your example code uses The issue we're discussing is allowing code before super() that does not touch Usually you can put code before super() that does not touch |
I've run into this as well, simply messing with parameters before passing them to |
@lukescott Yeah I know, maybe I did not express myself clearly. What I meant was that such strict TypeScript compiler behaviour does not protect the developers from making mistakes anyway (as in the example I provided) but it limits them in certain ways, resulting in workaround and less readable code. Thus I also think that code before super call should be allowed. |
Preventing "statements that does not touch this" before "super statement", leads to ugly code at first, but as well prevents construction objects in valid state. In below example, in line with comment "Complex calculation here", we do calculate valid value for super parameter (this calculation might take a few lines/statements). As of now, we would have to either:
All cases in this playground
In short: This limitation seems to not bring safety to written code, it just brings limitations. |
I think I found a bug, but since it’s directly related to this I’m not sure if it needs a separate issue. In the comments above (#8277 (comment), #8277 (comment)), people are talking about passing anon functions / immediately-invoked-function-expressions (IIFEs) as arguments to
The bug I’ve found is that this IIFE can reference constructor (opts: ReadableOptions) {
super((() => {
console.log(this) // oh no!
opts.objectMode = true
return opts
})())
} TypeScript in its current version (v3.9.2) does not emit an error, but when attempting to run this code, the JavaScript runtime will throw this error:
You can see a full playground here. Since Design Goal 1 is “Statically identify constructs that are likely to be errors”, I would propose adding to this issue by emitting an error for this use case. An IIFE passed as an argument to |
I just encountered error ts(2376). As far as I could see my use case hasn't been mentioned in this thread, so I'll add it as another motivation why it's desirable to allow other code before In my case, the super constructor has side effects that must only run if the object instantiation is successful; and the derived class must validate its constructor arguments and throw on invalid values. It looks like this: abstract class Node {
#parent: Node | null;
#children = new Set<Node>();
constructor(parent: Node | null) {
this.#parent = parent;
if (parent) parent.#children.add(this);
}
destroy() {
this.#parent?.children.delete(this);
}
}
class TextNode extends Node {
#text: string;
constructor(parent: Node, text: string) {
if (!text) throw new Error("`text` is required");
super(parent);
this.#text = text;
}
} Moving the line |
I think I found another bug: Calling class Foo {
constructor (n: number) {
if (n < 0) throw new RangeError()
}
}
class Bar extends Foo {
constructor (n: number) {
try {
super(n)
} catch {
// Expected a TS error here:
// > Constructors for derived classes must contain a 'super' call.
}
}
}
new Bar(-1) // runtime error (not predicted by TS):
// > must call super constructor before using 'this' in derived class constructor
// > Must call super constructor in derived class before accessing 'this' or returning from derived constructor |
I would like to add to this an argument why allowing code before the super call might be a good idea. My argument is the following: not allowing to call super after regular code will entice young programmers to call super with dummy values just to get it initialized. This will lead to existing objects that are in an dangerous internal state. Add lazy loading to the mix and you have essentially a race-condition generator. In other words, if there can be no regular code before calling super, people are enticed to do that after super which will leave the object in a dangerous state for a short time. You don't see that since every member is initialized! Here is a very long example (please only read that if you have time for an coffee break): This is loosely based on a bug I just found in our code-base. Please note, we started with pure JavaScript and have converted this code to TypeScript. In JavaScript you can call the super later, therefore this code is written this way (I assume, I didn't write it).
The problem is that you can't write this since you needs to call super first. So what we could have done:
There are two problems her: the code is way too long (this is our fault since we made this mess) and we can not use the same configBuilder object! The problem is, if that object has an internal state you basically have to handle this object via an parameter even if that doesn't make sense. Alright, someone came up with a easy but pretty stupid solution:
And it worked. Until someone did basically this:
And this was the point where all objects figuratively exploded if the internet connection was too fast or your device was too fast or the sun was felling that way. And since you work in a nice office with good internet connection this bug isn't too obvious. TLDR; If we can't run regular code before super it will entice the usage of dummy values for the super call. This can lead to really ugly bugs. |
Here's a toy example: declare class T {
constructor(x: number, y: number);
}
class H extends T {
x = 0;
constructor() {
const rand = Math.random();
super(rand, rand);
}
} this cannot be represented in TS. Actually, it can, like this: declare class T {
constructor(x: number, y: number);
}
class H extends T {
x = 0;
static rand = NaN;
constructor() {
super(H.rand = Math.random(), H.rand);
}
} that is ugly, but TS allows it. ¯\_(ツ)_/¯ Otherwise, I strongly believe that TS2376 is obsolete with the introduction of TS17009. |
TypeScript should not do this, an arrow function can be passed to As an example, this is a simplified version of something I'm already doing today (and works): class MathView {
readonly #render: () => Element;
constructor(render: () => Element | Promise<Element>) {
this.#render = render;
}
async render(): Promise<Element> {
const element = await this.#render();
return wrapAndSetMetadata(element, this);
}
}
class MathPlusView extends MathView {
readonly addend: View;
readonly summand: View;
constructor(addend: View, summand: View) {
super(() => this.#render()); // This works fine, when the public
// .render() is called, "this" will be available
this.addend = addend;
this.summand = summand;
}
#render = async () => {
const [addendElement, summandElement] = await Promise.all([
this.addend.render(),
this.summand.render(),
]);
return fromTemplate`
<mrow>${ base }<mo>+</mo>${ superScript }</mrow>
`;
}
} Now TypeScript could detect "this" within IIFEs within |
@Jamesernator An IIFE (immediately-invoked function expression) is a function that is called immediately after it is defined. In your example, you have a regular arrow function, but it’s not called right away — as you correctly state, it doesn’t get called until the public In my example, an IIFE is sent as an argument to super((() => {
console.log(this) // ReferenceError!
})())
// ^ notice the call here — the function expression is invoked immediately Your code poses no danger, as the argument sent to |
The TypeScript specification currently reads:
It is reasonable in TypeScript to not permit
this
to be referenced in a constructor before callingsuper
when there are initialized properties or constructor parameter properties becausethis
is not fully initialized until aftersuper
is called. But broader restrictions on calling other code beforesuper
that is not directly usingthis
don't seem that helpful and can be worked around anyway. So why keep them?A common use case for having code before a call to
super
is to transform constructor parameters in the subclass constructor before passing them to the superclass constructor. If such transformations are complex, a programmer might want to do the transformation step-by-step on multiple lines for increased readability and easier debugging.An example of bypassing the compiler's restriction of no code before
super
is just making function calls wrapping arguments to asuper
call such assuper(logThisName(name))
where the called function refers tothis
.As show by an example in the Handbook discussion linked below on improving the explanation for TypeScript constructor restrictions, ES6 permits other code in a constructor before a
super
call (although accessingthis
in called code would generate a runtime error before super was called). TypeScript is being more strict than what ES6 permits, and sometimes that is a good thing. But, is there any real value in this case by differing from what ES6 allows overall -- compared to just getting in the way? Why not always always allow code before asuper
call when it does not usethis
? Does the benefit of not allowing code before asuper
sometimes really benefit anyone compared to the confusion caused by requiring programmers to use awkward workarounds and to learn a more complex rule for writing constructors than "Don't usethis
before callingsuper
"?This idea was originally brought up in issue #945 (closed in October 2014). I am creating a new issue for that as discussed with @mhegazy here: microsoft/TypeScript-Handbook#214. There is a code example in that Handbook issue which can be used for testing the current behavior for TypeScript, Babel, and ES6.
The text was updated successfully, but these errors were encountered: