Why can't `#private` fields return undefined before initialization?

There are so many cases of constructors calling methods, and subclasses overriding those methods and using class properties. When converting such code bases to attempt to use #private fields for implementation details, it just doesn't work, due to the temporal dead zone of private fields.

Why can't they just return undefined during a super() call, and why can't they just be accessed or written in a subclass's method if super() calls a method that the subclass overrides or extends?

Private fields don't have a temporal dead zone, they simply don't exist on the instance until base class constructor finishes executing. They can't be installed before that point because the base constructor is the thing which creates the object in the first place.

As a general rule, you should avoid calling derived methods in base class constructors. Derived methods can (as in this case) rely on state which the derived class constructor sets up, and that hasn't happened when the base class constructor is running. This is true in every OO language; it's a good rule to learn.

As to your particular suggestion, falling back to undefined for private fields which haven't been set up wouldn't help in most cases, because you'd usually actually need to know what the value of the field should be. If you're just using the fallback to undefined to be defensive against running before the derived class constructor has executed, you can use if (#x in this) { ... } instead of if (this.#x === void 0) { ... }.

2 Likes

It's effectively like a temporal dead zone. It currently throws an error, just like a TDZ does. The error message doesn't make a practical difference for most use cases.

In the vast majority of cases, this is the same object throughout a class hierarchy. Straying away from that is not even practical for class inheritance, except with Proxy (which we all know breaks with private fields).

I'm implying that private fields could have been designed differently (I'm not asking why they are they way they currently are), with actual WeakMap semantics:

class Base {
  constructor() {
    this.someMethod?.()
  }
}

class Foo extends Base {
  someMethod() {
    this.#foo = 123
  }
}

could hypothetically have been designed to desugar to something more like

class Base {
  constructor() {
    this.someMethod?.()
  }
}

const foo = new WeakMap

class Foo extends Base {
  someMethod() {
    foo.set(this, 123) // no error, works for the vast majority of use cases.
  }
}

And for the average case (the majority case) this would be perfectly usable, even in super() calls that call subclass methods.

I know we're going in circles now. But the current private fields are just bad.

That's not a hard rule. Better to design language features that work better for most cases, including those that currently exist.

People do this all the time. For example.

The point of having private fields is to allow the class to enforce invariants about its instances. Silently exposing partially-initialized fields to methods in the class is worse than having an error. If you actively want to deal with the object in a partially initialized state, you can do that explicitly, as mentioned above.

could hypothetically have been designed to desugar to something more like

That would mean that invoking the method on a non-instance would install the private field on a non-instance, instead of giving an error. That is not a good outcome.

Better to design language features that work better for most cases, including those that currently exist.

It's my belief that the current design works better for most cases, which is why we went with it.

Anyway, I've said my piece. You are welcome to disagree, but I doubt there's much more I can say here, so I'm going to step away now.

1 Like

Though, if we actually look at that use case, they're calling that initialize function after all the fields have been initialized, which means the derived method is still working with a fully-initialized object. Well, almost, for some reason they're setting the idAttr property after they call initialize(), presumably because it didn't matter in this scenario, but it would have been better if they had just done that before they called initialize(), in case the derived initialize() functions needed access to that property.

The spirit of @bakkot's rule is that derived methods shouldn't be called while the instance isn't fully initialized. So, calling a derived method at the end of a constructor should be just fine.

I can certainly understand the pain of migrating code that wasn't built to support private fields, but outside of that use case, I'm not sure there's really much of a difference between either behavior (you either do your is-this-field-defined checks by doing an undefined check, or a #field in obj check, and that's about it). And, perhaps it goes against the loose-natured spirit of JavaScript to not default-initialize it to undefined :man_shrugging:

1 Like

This is a troublesome problem. On one side, it isn't safe to call members of a derived class until the derived class has been fully initialized. On the other side, it isn't safe to call an initialization function outside of the constructor chain.

It isn't something I'd ever put into the language, however I've been thinking of an initializer function to handle these order-of-operations problems in the constructor. It would allow you to supply a pre-init, init, and post-init function so that everything could be handled in the constructor chain properly. Don't know if it's worth it though.