JS Class fields potentially harmful

I've used the same title of this blogpost, if you have 6 minutes (??? da hell Medium!) to read it, but the gist is that while everything defined within a class declaration (developers' eyes and syntax) is already available at instance definition time, class fields end up surprising developers.

The ClassFieldDefinition Record Specification Type doesn't mention when the Record is being considered, and The List and Record Specification Types doesn't mention neither (did I miss it?) when the class fields are attached to the just created instance.

In practice, differently from any other class public, private, static or accessor that are available at instantiation time, class fields are available "who knows when" during the instantiation process.

I feel like an example could speak thousand words here:

class A {
  value = 'a';
  constructor() {
    console.log(this.value);
  }
}

class B extends A {
  value = 'b';
}

no constructor dance is even needed to realize that new B will log "a" instead of the expected value, defined at B class level, which is "b".

Where's the inconsistency?

These are all preserved developers' intent at class definition time:

class A {
  static value = 'a';
  static getValue() { return this.value }
  shenanigan = 'a';
  get value() { return 'a' }
  getValue() { return this.value }
  constructor() {
    console.log(
      this.constructor.value,
      this.constructor.getValue(),
      this.shenanigan,
      this.value,
      this.getValue()
    );
  }
}

class B extends A {
  static value = 'b';
  static getValue() { return this.value }
  shenanigan = 'b';
  get value() { return 'b' }
  getValue() { return this.value }
}

new B;
// "b", "b", "a", "b", "b" !

The private field is some kind of fresh new hell, because the following isn't just ambiguous, it throws an error (rightly so, accordingly with the current upside-down logic, in terms of expectations):

class A {
  #value = 'a';
  get value() { return this.#value };
  constructor() {
    console.log(this.value);
  }
}

class B extends A {
  #value = 'b';
  get value() { return this.#value };
}

new B;

// Uncaught TypeError: can't access private field or method:
// object is not the right class

excuse me? ... which part of my definition is not in the right instance object I've just created?
answer the accessor tries to reach A#value instead of B#value which is defined in B prototype :upside_down_face:

The chicken/egg elephant

I understand that if fields are inherited, like anything else, the reason previous examples don't throw an error, rather show what's attached to the class A {} at its definition time, but at the same time if a field is defined at the sub-class definition, it's not clear to me why that's the only exception to the "inherited from the prototype / class definition time" rule any other stuff defined in the sub class benefits from.

In short, I believe this is a footgun backed in the current specification, and I am raising as much concerns and awareness I can hoping this is not going to land forever as JS feature, because me, among others, already have been bitten by this counter-intuitive exception to the class definition rules, so please tell me there's hope this stuff will go out in a better shape, thank you :pray:

2 Likes

Proposal ... if it's hidden attached fields we're talking about, I would flip the order to right before super() instead of right after super().

As mentioned in the post, the way I see classes fields de-sugared is like:

// let's ignore private for demo sake
function A() {
  this.value = 'a';
  console.log(this.value);
}

function B() {
  A.call(this);
  this.value = 'b';
}

new B; // "a"

What instead should happen is, imho, the following:

// let's ignore private for demo sake
function A() {
  if (!Object.hasOwn(this, 'value'))
    this.value = 'a';
  console.log(this.value);
}

function B() {
  // the attach class fields point
  if (!Object.hasOwn(this, 'value'))
    this.value = 'b';
  // the super() stack point
  A.call(this);
}

new B; // "b"

Of course parsers and engines can make that check way faster than any ES3 example code could, but I am sure you got where the issue lands when it comes to class fields.

P.S. to add shenanigans to the equation, the following code "surprisingly" doesn't break developers expectations:

class A {
  static value = 'a';
  constructor() {
    console.log(this.constructor.value);
  }
}

class B extends A {
  static value = 'b';
}

new B; // "b"

Please fix class fields already :pray:

In your very first example, I think the contention is what's "expected" there.

I would not expect, in any form of inheritance, for a superclass constructor to run code after a subclass is initialized. Meaning, I actively expect the current ordering.

Either way, I doubt it's web compatible at this point to change the ordering, since so many transpiler transforms, at the least, depend on it.

1 Like

please don't stop there ... more shenanigans are shown after!

This is not what I am proposing, please don't stop at the first example. I've added 2 extra follow up.

They are not doing any developer or DX a favor in doing so ... this issue surprised 79% of developers unaware of the issue itself, few are already refactoring code, few others couldn't find a solution (there is none), so that this is not what writing JS at class definition time should look like, ending up on developers shoulders.

It's a footgun to explain how to avoid, not a real-world feature as it is right now!

to simplify the issue: please explain like I am 5 why everything defined at class declaration is inherited right away when new Class is used, except for instances properties class fields declaration nobody can deal with throught their own code ... if you can answer that in a reasonable way, we can close this post.

i'm not sure what you mean by "nobody can deal with through their own code" - a class field is initialized with Object.defineProperty semantics immediately after super, or as the first line in the body of a base class - so you could replace eg:

class Y {
a = 1;
constructor() {
console.log(this.a);
}
}
class X extends Y {
a = 2;
constructor() {
super();
console.log(this.a);
}
}

with:


class Y {
constructor() {

Object.defineProperty(this, 'a', { enumerable: true, configurable: true, writable: true, value: 1 });

console.log(this.a);
}
}

class X extends Y {
constructor() {

super();
Object.defineProperty(this, 'a', { enumerable: true, configurable: true, writable: true, value: 2 });
console.log(this.a);
}
}

both of which would log 1, and then 2. With your proposed change, if I understand it correctly, they would log 2 twice.

yes, twice, and that'd be expected ... counter example:

class Y {
  get a() { return 1 }
  constructor() {
    console.log(this.a);
  }
}

class X extends Y {
  // OVERRIDE !!!
  get a() { return 2 }
  constructor() {
    super();
    console.log(this.a);
  }
}

new X; // 2, 2

so, why everything defined at class definition is available as expected during new Phase initialization, except for the own instance properties class fields? Because statics class fields also would show up with expected value once initialized or accessed during the constructor phase.

P.S. all the uses cases you can come up with are already exposed in my post and follow ups ... please read it carefully or I gonna copy and paste what's written already, thanks!

to be terser, this case represents my argument, please try and test or read that case and justify the shenanigan in it, thanks.

I understand your argument - but it rests on assumptions of intent/intuition that are subjective, not objective. Maybe others will find your argument compelling, but such a fundamental change for a stage 4 proposal would likely require a much higher bar than I think you've presented here - in other words, the ship sailed years ago for this semantic to be debated on these terms.

last from me (I lied): I doubt this is a real-world concern because most people are finding the issue rather than enjoying the feature ... it's really hard to explain why something defined when the class is declared, doesn't end up as already inherited (override) / available when the new Class is invoked.

There is no simple explanation to that except "we didn't think about it" to me, so I am all ears to understand what's the rationale about such inconsistency for a language already famous for those here and there, and any transformer can easily move the declaration, that doesn't even need an Object.defineProperty as that's writable, configurable, and enumerable last time I've checked, and as you wrote there, before the super() call is executed.

edit OK for Object.defineProperty for those configurable, not writable, inherited cases ... plot-twist, this whole issue is exactly about the fact there will be issues with that too, but I am not questioning the transpilers choice, I am questioning what people write, and expect, as code.

P.S. stage X is a stage, it doesn't mean shipped ... I've already fixed (pushed for) non enumerable class fields (ES2015) right before it became a standard, I'd be more than happy to fix this footgun before it becomes the standard ... that, of course, unless anyone can show me any benefit of declaring classes with instance fields that is better off with the current standard (not yet) behavior.

On the other hand, I question the staging steps if nothing can be changed right when people just started using the new features.

FWIWI, the list of replies around developers wasting time and circumventing this stage 4 proposal is rising, not decreasing, after I've tried to make developers aware of such issue.

intentions are standards defined and standards are shipping an inconsistent expectation around developers intent here ... this is not a subjective matter, this is a footgun in the specifications.

I think I've written already all cases where this stage 4 proposal fails short, but I'm still happy to hear when this proposal would be deisred, as opposite of being a footgun easy to overlook at.

if anything to add, the argument "expectations are subjective" really doesn't solve the issue I am rising here ... a sub class cannot ever expect to reach what's defined on the parent class as class field:

class A {
  value = 'a';
}
class B extends A {
  value = 'b';
  constructor() {
    // how am I supposed to reach or use A.value here?
    super(); // it's gone, any access to value now is 'b'
  }
}

So, inheriting at super() call the parent class fields has no practical use in the real-world, but it exposes infinite issues to any class that would like to be as generic as possible, including its own class fields declaration (let's say it's abstract, an interface, or anything among these lines).

If the super class field value could be beneficial at any time for developers, then I'd say "OK, some use case might desire having parent class fields overriding current class fields out there" but the more I think about it, the more I believe this is breaking fundamental OOP expectations and there's no way an inherited class field, except those expected with a default and never useful for anything else except initializing current instance through the root class, can help developers, or logic in general.

I am really asking to understand why the current behavior was accepted in the first place, as I cannot possibly find any reason to favor it instead of being able to override class fields like being able to override everything else in the class definition.

I mean, imagine the headline "JavaScript classes can extend and override, except when they cannot!" how welcomed could be for developers trying to do OOP from other languages ...

Python (a stretched example not 1:1 comparison, just to show developers intent on definition)

class A:
  value = 'a'
  common = 'ok'
  def __init__(self):
    print(self.value)
    print(self.common)

class B(A):
  value = 'b'

B()
# b
# ok

PHP (also a stretched comparison to show developers intents)

<?php
class A {
  public $value = 'a';
  public $common = 'ok';
  public function __construct() {
    echo $this->value . "\n";
    echo $this->common . "\n";
  }
}

class B extends A {
  public $value = 'b';
}

new B;
// b
// ok
?>

JavaScript

class A {
  value = 'a';
  common = 'ok';
  constructor() {
    console.log(this.value);
    console.log(this.common);
  }
}

class B extends A {
  value = 'b';
}

new B;
// a
// ok (not really)

Somebody mentioned in Python value that way ends up in the prototype ... and maybe in PHP it's the same, don't focus on implementations details though, focus on developers intents in classes definitions.

If nothing to add, in JS own properties rule over prototype properties, which adds extra surprise to me class fields are going out like this!

The paradox here, is that even writing this code there's no escape to the current result, because the prototype gets overwritten in class A through its fields, just to add confusion to developers (in this case).

class A {
  value = 'a';
  common = 'ok';
  constructor() {
    console.log(this.value);
    console.log(this.common);
  }
}

class B extends A {
  value = 'b';
}

B.prototype.value = 'b';

new B;
// a
// ok (still ...)

Correct, you're not supposed to. If you need to share functionality with a subclass, methods and statics are the way to do that.

or we actually make sense here like other PLs ???

also ... the issue there

how any root class is supposed to know how it's going to be extended? can we see the nonsense in the current specification?

or maybe ... are you suggesting classes in JavaScript are final by default? in that case I'd rather see an error when a subclass extends an own field that is used in the parent constructor (at least), otherwise we're making all classes final without even knowing it (covered in my blog post).

"Don't call subclass methods from a superclass constructor" is not a rule JavaScript invented, and is a problem with or without class fields in JS - subclass methods can rely on state set up by the subclass constructor, which includes presence of private fields but also includes all other state. The exact same problem in your first example in the blog post would arise if you had used the old this.onclick = ... pattern in the constructor instead, no class fields required.

And more generally, you want the initialization of a field in the class body to be sugar for initializing it in the constructor, since that's a transform you need to do often (when some state needs to be passed in rather than always starting the same for every instance). That's why bare x; field declarations are legal, so that you can keep the declaration in the body while moving the initialization into the constructor. It would be bad to separate "class field initialization" from the rest of the initialization which happens in the subclass constructor.

(Also, running field initializers before the superclass constructor fundamentally cannot work, because the superclass is the thing which creates the object in the first place - the superclass constructor could be function(){ return {}; }, so how could you possibly install fields on the instance before invoking the superclass?)

All parts of instance initialization have to happen in some order. "Class fields are initialized at the same time as other per-instance state set up by the constructor" and "superclass constructors run before subclass constructors" are both obvious rules to follow, and together imply the current state of things.

Incidentally, the only reason it looks like Python works differently is because class attributes in Python are shared, leading to this surprising behavior:

class A:
  value = []

x = A()
y = A()
x.value.append(0)
print(y.value) # [0]
4 Likes

I haven't called any method ... I've accessed a property but, if I call any method, I expect that method to be the one in the prototype ... this links doesn't really bake your argument, it enforces mine: methods, if called, are those inherited in the sub-class, while properties, defined as own in the class definition, are those nobody can deal with with the current state.

I will read the rest and come back tomorrow, as it's 1AM here and I think I've said everything I had to say, but I can't focus on extra answers right now. Sorry about that.

1 Like