proposal-class-fields icon indicating copy to clipboard operation
proposal-class-fields copied to clipboard

Could we have avoided "fields"?

Open rdking opened this issue 4 years ago • 39 comments

I wish Stage 3 was still a time when TC39 is open making major changes to a proposal. However, since it isn't, I can only ask these questions in hindsight.

  1. Is the prototype foot-gun the primary reason for avoiding the prototype?
  2. If so, was any consideration given to a syntax for avoiding the issue directly?

Here's what I'm thinking: suppose the syntax being used for public fields was instead used for prototype properties. I can think of several different approaches that solve the problem directly.

  1. Use a keyword to specify delayed initialization:
class Ex {
   inst object = {};
}
  1. Use an operator to specify that the initializer is an initialization function:
class Ex {
   object => {};
}
  1. Use a helper pseudo-function to wrap the initializer in an initialization function:
class Ex {
  object = class.inst({});
}
  1. Use a decorator to manage the initialization:
class Ex {
   @inst object = {};
}

All in all, any of these would successfully avoid the foot-gun without incurring any of the issues that will exist as a result of the current proposal. All 4 are relatively simple to implement. The only negative result of such a shift would be that private fields would need to be reworked. However, even that has a few solutions. But right now, what I'm looking for is an evaluation of whether or not this kind of idea presents a viable solution to the foot-gun, and if such a solution would have been enough to warrant dropping the "fields" concept (barring the issue of private) should it have been presented before Stage 3.

rdking avatar Oct 06 '19 18:10 rdking

This seems like a great idea!

fabiosantoscode avatar Oct 07 '19 10:10 fabiosantoscode

Is the prototype foot-gun the primary reason for avoiding the prototype?

It's a huge reason. But placing properties on the instance is the expected behavior for people migrating from imperative this.x = 1 in the constructor.

If so, was any consideration given to a syntax for avoiding the issue directly?

Your suggestion here is to let people do the bad thing by default, and have them opt into the good behavior? That's backwards. If you want to shoot yourself in the foot, you can already do it with the good-by-default semantics:

const data = {};

class FootGun {
  data = data;
}

Let me be super blunt about this: The committee will only accept a proposal that installs a fresh object onto the instance. No one on the committee will take a proposal that breaks this requirement seriously, so it's waisting our time to hash this out again.

jridgewell avatar Oct 07 '19 18:10 jridgewell

Your suggestion here is to let people do the bad thing by default, and have them opt into the good behavior? That's backwards.

Agreed. However, it's already the language default. At the same time, as long as the foot-gun can still be enabled, I could care less whether or not it's the default.

But placing properties on the instance is the expected behavior for people migrating from imperative this.x = 1 in the constructor.

My question here is simply "Why?". The copy-on-write(CoW) semantics of prototypes guarantees that any primitive on the prototype is immediately set on the instance object as soon as it is changed. The only thing that's ever been missing is CoW semantics for objects on prototypes. Unless I'm missing something, if all non-primitive initializers defaulted to constructor initialization, or even if all initializations defaulted to constructor initialization, but the definitions were placed on the prototype, not only would we have sanity back in the inheritance chain, but we'd also satisfy this curious desire for instance properties.

So I'm left with wondering why this path is not the chosen one given that it seemingly satisfies the requirements without breaking anything unnecessarily.

rdking avatar Oct 07 '19 22:10 rdking

In case it isn't clear, I'm not trying to re-hash old arguments. I am very much aware of the fact that no one in TC39 has any intention of amending any of the excessively numerous issues with this proposal. That's not my goal. I just want to understand the details of how ...this ... became the "best we can come up with".

rdking avatar Oct 07 '19 23:10 rdking

Agreed. However, it's already the language default.

The language default is this.data = {} in a constructor. Patching data onto the prototype is the anti-pattern.

My question here is simply "Why?". The copy-on-write(CoW) semantics of prototypes guarantees that any primitive on the prototype is immediately set on the instance object as soon as it is changed.

There is no COW behavior… Mutate it once an every instance is screwed.

Properties can be shadowed by instance properties. this.x = 1 and this.x.prop = 1 are to very different behaviors.

not only would we have sanity back in the inheritance chain, but we'd also satisfy this curious desire for instance properties.

How is this sane? Instance data does not belong on the prototype, only methods.

jridgewell avatar Oct 08 '19 03:10 jridgewell

The language default is this.data = {} in a constructor. Patching data onto the prototype is the anti-pattern.

The language default is:

  • Any object can hold data and functions.
  • Any object can be used as a prototype.

The notion of an anti-pattern is both secondary and temporary as it is something using developers decide and not something encoded into the language itself. The notion of coding a language to avoid these so-called anti-patterns is a mistake as what is considered an anti-pattern today can easily re-surface as a very useful pattern, and what is considered best practice today can easily plummet into anti-pattern status. As someone who's been using JS since it was first released, I've seen this happen a few times. Not only in JS, but in many different languages.

There is no COW behavior… Mutate it once an every instance is screwed.

I'm assuming you're talking about for objects, as any attempt to change a non-nested prototype value causes the new value to be written to the instance instead of the prototype. Or maybe you don't view this as CoW behavior?

Properties can be shadowed by instance properties. this.x = 1 and this.x.prop = 1 are to very different behaviors.

Part of my stance is that (assuming x is on the prototype object), not only is this true, but it's not the best of defaults. When an object serves as the prototype of an instance, any write attempt to the object's properties while using the instance as a receiver should cause that property to be written to the instance. This already happens. I also hold that something should be done to cause the same thing to happen even if a property of a property of the prototype is written to under the same circumstances. Put another way, writes through an instance to any part of the prototype should force the property on the prototype to be copied to the instance and the modification redirected to that new property.

How is this sane? Instance data does not belong on the prototype, only methods.

  1. The data elements of a class definition should specify the default values for the those elements.
  2. Should any instance need to change the value of any part of one of those elements, it should do so in its own instance data and shadow the prototype.

This is the sanity. No instance object should ever need to keep a copy of data that has never been modified from the defaults. Should any modification occur, then that modification should belong only to the instance. The only thing that makes this difficult is that writes to an object on the prototype don't redirect the write to the instance object, hence the foot-gun.

Put another way, other than objects, having data on the prototype does not preclude the object from having or creating instance data when it needs a different value than the default. This is how objects have always worked in JS/ES. All of the so-called "best practices" that have been built up in communities like React exist primarily because the foot-gun has and continues to do so much damage that developers have decided to completely avoid any risk despite the costs, and seemingly without attempting to remedy the issue directly.


Please understand that I'm not trying to convince you to accept or even understand my stance, as I'm sure you either can't or won't, and probably for reasons that I could accept if I knew them. But that is also the point of my post. TC39 has reasons for their (IMO) peculiar choices. I just want to understand them. If I can do that, then if/when this proposal lands, maybe I can accept it for what it is, rather than continuously decry it as an unfortunate waste of really good effort.

rdking avatar Oct 08 '19 04:10 rdking

The notion of an anti-pattern is both secondary

Absolutely not. Our responsibility is to design a language that works. Codifying something we know is harmful is the opposite of progress.

and temporary as it is something using developers decide

We've known about this since the Backbone days (2012 when I started using it), and Ember.Object days.

and not something encoded into the language itself. The notion of coding a language to avoid these so-called anti-patterns is a mistake as what is considered an anti-pattern today can easily re-surface as a very useful pattern, and what is considered best practice today can easily plummet into anti-pattern status.

We're coding it into the language. This is an anti-pattern. The footgun still exists if you really want it, as I've shown.

Or maybe you don't view this as CoW behavior?

Overshadowing is not COW. If prototype.data = {x: 1}, and I do this.data = {}, my new data object doesn't have an x property. The same thing applies when the prototype property was a primitive value.

Put another way, writes through an instance to any part of the prototype should force the property on the prototype to be copied to the instance and the modification redirected to that new property.

That's where you wrong, there is no copy here. This is installing a new property with the same name and a new value. Designing a new feature to do a copy isn't the cowpath we're paving here. We have years of class usage telling us people understand assignment to instances (that have a prototype), and that assignment doesn't copy.

Should any instance need to change the value of any part of one of those elements, it should do so in its own instance data and shadow the prototype.

Data shouldn't exist on the prototype. If it were to exist on the prototype, we know (via years of bugs) that they will just do this.data.x = 1.

Every single class based framework in popular use assigns data to the instance as the best practice. Is there any example that does what you're suggesting? If not, why would we design a new feature that breaks with what we know is the best practice?

that developers have decided to completely avoid any risk despite the costs, and seemingly without attempting to remedy the issue directly.

There is no downside with the current design's installing data on the instance, and the upside is that everyone will understand it.

There is no upside to installing them on the prototype, and the downside is bugs.


I don't even understand why this needs to be debated. We don't live in a world with some hypothetical COW semantics, and that's not going to change. Designing fields as if we do would be a disservice to JS developers.

jridgewell avatar Oct 08 '19 05:10 jridgewell

Let's just pretend this is a good idea for a bit:

const v = Cow({ x: 'x', y: 'y' });

// Assignment is COW, so `v.x = 1` won't write to v.
v.x = 1;
assert(v.x === 'x');
assert(v.y === 'y');

// So we know the assignment must return a new object.
const v2 = (v.x = 1); 
// BTW, `const v2 = v.x = 1` won't work and that can't be changed.
// It would be the same as `const v2 = 1`. Footguns…

assert(v2.x === 1);
assert(v2.y === 'y');

So now, to have a COW prototype property:

class FootGun {
  // Pretend this is installed on the prototype
  data = Cow({ x: 'x', y: 'y' });
}

const fg = new FootGun();

// This won't work, because we know the assignment returns a new object.
fg.data.x = 1;
assert(fg.data.x === 'x');

// So, we have to do:
fg.data = (fg.data.x = 1);

fg.data.x = 1 has no knowledge that data is a property of fg (as far as the spec is concerned, this is the same operation as data.x = 1), so it couldn't ever do the auto-write to fg for you.

So now we have an even worse situation where our COW isn't writing our assignments at all. Because no one is going to remember to do fg.data = (fg.data.x = 1). They're just going to do fg.data.x = 1, and that'll return a new Cow({ x: 1, y: 'y' }) object.

Is there any C-derivative language that has this "feature"?

jridgewell avatar Oct 08 '19 05:10 jridgewell

Absolutely not. Our responsibility is to design a language that works. Codifying something we know is harmful is the opposite of progress.

... and yet, this proposal...

We've known about this since the Backbone days (2012 when I started using it), and Ember.Object days.

But what you started calling a foot-gun since that time had been common practice for quite some time, so much so that many actually found a good way to use it. That's why there's code out there you can't afford to break by fixing the foot-gun directly.

Overshadowing is not COW.

I can accept that.

That's where you wrong, there is no copy here.

I just did a test and surprised myself. I was under the mistaken impression that the new property on the instance received the attributes of the old. Until just now, I never noticed these attributes weren't being copied. My mistake.

Data shouldn't exist on the prototype. If it were to exist on the prototype, we know (via years of bugs) that they will just do this.data.x = 1.

Despite my error, this statement still seems wrong. Early documentation about prototypes described them as templates, or a means of sharing existing content between multiple objects. That's also where my CoW understanding came from, so take it with a grain of salt :smiley:. Further, you can't do this.data.x = 1 if this.data is a primitive, or if this.data.x is non-writable, or if this.data is frozen, or any of several other limitations.

Every single class based framework in popular use assigns data to the instance as the best practice. Is there any example that does what you're suggesting? If not, why would we design a new feature that breaks with what we know is the best practice?

Because (IMO) you've been too focused on the "what" and not the "why". There's 3 simple reasons why the "best practice" is what it is:

  1. The foot-gun exists.
  2. There's no direct syntax support for declaring data in a class.
  3. The next best practical and logical option that still generally gets you close enough is to set them in the constructor.

If the first 2 reasons had been dealt with directly when class was being developed, I do not believe 3 would have ever become a best practice. But this only partially answers the question. The other part is because since [[Define]] is the mode-du-jour, inheritance is broken in a peculiar way and requires developers to be even more careful lest they find themselves hunting for a foot-gun that leaves even less clues than the one you're trying so hard to avoid. Inheritance is a major piece of one of the 3 core concepts behind the concept of a class. If you cripple that in any way, you've done serious damage to its usability and usefulness.

There is no downside with the current design's installing data on the instance, and the upside is that everyone will understand it.

Are you really ignoring the "base overrides descendant" problem? Even if you want to describe it as an edge case, it shouldn't be ignored.

I don't even understand why this needs to be debated.

This is not a debate. I'm challenging myself by challenging you. This is me trying to understand TC39's decisions and why they differ so greatly from how I would have chosen. I've already found 1 error in my understanding thanks to you, but that 1 error is no where near sufficient enough to explain such a large deviation. There's got to be other rational reasons, other things I either don't know or have a wrong understanding of.

Let's just pretend this is a good idea for a bit:

First, since I was wrong, let's call it initialize-on-write (IoW) since the instance object is initialized with the new property when you overwrite a value on the prototype. Second, that example isn't the idea. Its more like this:

const v = IoW(Object.create({ x: 'x', y: 'y', z: { a: 'a' } }));

// Assignment is IoW, so `v.?(.?) = 1` will only write to v.
v.x = 1;
//assert(v.x === 'x'); //throws
assert(v.x === 1);
assert(v.y === 'y');
v.z.a = 'alpha';
assert(v.z !== v.__proto__.z);
assert(v.z.a === 'alpha');
assert(v.__proto__.z.a === 'a');

The idea follows this kind of logic:

let proto = {
   a: {}
};
let x = IoW(Object.create(proto));

//All 3 of these directly change proto
proto.a.alpha = 1;
x.__proto__.a.beta = 2;
Object.getPrototypeOf(x).a.gamma = 3;
assert(!x.hasOwnProperty("a"));

//This, however, initializes x with a new copy of `proto.a` 
//and changes that new property instead of the prototype.
x.a.delta = 4;
assert(x.hasOwnProperty(a));
assert(x.a.hasOwnProperty(alpha));
assert(x.a.hasOwnProperty(beta));
assert(x.a.hasOwnProperty(gamma));
assert(x.a.hasOwnProperty(delta));
assert(x.a.alpha === 1);
assert(x.a.beta === 2);
assert(x.a.gamma === 3);
assert(x.a.delta === 4);

The idea is that since the prototype interface of an object is not itself a property of the object, it should not allow [[Set]], [[Delete]], or [[DefineProperty]] calls to cross it. You can look here to see a Proxy-based, 90% solution. Can't seem to get around Proxy invariants for the seal/freeze cases. If I was thinking on the same lines as what you're showing in that second post, I would have found those same reasons and dropped the idea a long time ago, or it would have eventually mutated by a series of corrections into what I'm trying to show you now.

rdking avatar Oct 08 '19 13:10 rdking

common practice for quite some time, so much so that many actually found a good way to use it. That's why there's code out there you can't afford to break by fixing the foot-gun directly.

This is incorrect. It can't be fixed because it would fundamentally change the language. There is no precedent for COW behavior you're asking for in any language I'm aware of.

Further, you can't do this.data.x = 1 if this.data is a primitive, or if this.data.x is non-writable, or if this.data is frozen, or any of several other limitations.

Because immutable data has no bug associated with it. That's why it's ok to store methods on the prototype, the function's code (not the function instance) is immutable. Setting properties on the method itself is strange, but almost never changes the function's running code.

Are you really ignoring the "base overrides descendant" problem? Even if you want to describe it as an edge case, it shouldn't be ignored.

This is specific to define semantics, and has nothing to do with prototype-COW or data-on-instance.

// Assignment is IoW, so v.?(.?) = 1 will only write to v. v.x = 1; assert(v.x === 1); //This, however, initializes x with a new copy of proto.a //and changes that new property instead of the prototype. x.a.delta = 4; assert(x.a.delta === 4);

These are completely incompatible. There is no distinction between v.x = 1 and a.delta = 4, the must do the same thing. Again, x.a.delta is completely unaware that a is a property of x.

So, if assignment were to actually mutate the object, we don't have COW anymore.

// Given mutation
new X().a.delta = 4;

assert(x.a.delta === 4);

// We have the same footgun, again.
assert(new X().a.delta === 4);

I've already found 1 error in my understanding thanks to you, but that 1 error is no where near sufficient enough to explain such a large deviation. There's got to be other rational reasons, other things I either don't know or have a wrong understanding of.

Do you understand how much time it takes to respond to your multiple issue threads? It's ad nauseam, with reopening discussions we've already settled on.

It's not an appropriate use of our time to work out pie-in-the-sky ideas, especially when it slows down progress on an already-stable, completely unrelated proposal.

jridgewell avatar Oct 08 '19 14:10 jridgewell

It can't be fixed because it would fundamentally change the language.

We agree here, but it's not like there aren't available approaches to make this an "opt-in". It's also not like with the addition of data declarations in class, that the option becomes the default in the presence of those declarations. It's no different than class forcing "use strict".

There is no precedent for COW behavior you're asking for in any language I'm aware of.

Me either, but neither has any other language I'm aware of been in this particular predicament. Class templates are immutable in every language I can think of that supports them. That's why I think the prototype interface should be responsible for protecting the attached object unless that object is directly addressed.

This is specific to define semantics, and has nothing to do with prototype-COW or data-on-instance.

That's only half-true. It's specific to define semantics when used on the instance and not the prototype. When define semantics are used on the prototype, that problem doesn't occur, and you're only left with the well known, remediable foot-gun.

There is no distinction between v.x = 1 and a.delta = 4, the must do the same thing.

Now we're at the meat of it. Why must they do the same thing? Initially, x.hasOwnProperty("a") === false. This means the engine has to do extra work to retrieve a from the prototype object. Now imagine, if that extra work included returning a reference that the language treaded as === a but additionally:

  • is flagged as
    • having been retrieved through the prototype interface
    • having been retrieved via x (the receiver)
  • is immutable
  • redirects own property altering attempts back to the receiver

It's the simple fact that ES puts in effort to hide the fact that a is not an own property of x unless you specifically ask that makes this a viable possibility.

Do you understand how much time it takes to respond to your multiple issue threads?

Yes, I do. And that's why I am so appreciative when one of you takes the time to respond (even if it doesn't always seem like it from the way I write). And while I understand the frustration you must be experiencing going over what is to you a closed issue, I'm sure you can likewise see similar frustration by those of us who see this proposal as being more damaging than useful. Even for as thorough as I'm certain you all were when making these decisions, I still think you either glossed over or failed to notice certain possibilities that would have rendered a technically better result. Almost as though it was intended to be proof, you have shown multiple times that you did not understand the concept I was trying to show you. That may be my fault, and if it is, I apologize for not being clear enough.

This is the reason I kept asking for over a year for someone to post a complete set of the requirements behind the decisions. The FAQ is not that complete set, not even in its current form. Had that been available, I am almost absolutely certain that I would have either been quieter, or managed to convince one of you that a serious miscalculation had been made... or maybe even both. As a programmer, it's always been my nature to try and stop problems I see coming before they arrive. The cost of this proposal is higher than the benefit it provides, even if not by much. However, there are ways to lower that cost without giving up on any of the requirements I'm aware of. That's why I'm confused and keep raising questions.

rdking avatar Oct 08 '19 15:10 rdking

It's also not like with the addition of data declarations in class, that the option becomes the default in the presence of those declarations. It's no different than class forcing "use strict".

It's very different than either of these, it changes the fundamental MOP operations the language is built on. You're designing a brand new, not-JS language.

Now we're at the meat of it. Why must they do the same thing?

Because this is how the MOP is designed! The operation you're doing is a set a.x = 1 (aka, SET(base = a, property = x, value = 1). The get operations x.a is already done, nothing can change that. To be able to propagate a parent reference in this would completely change the way object operations are performed.

I still think you either glossed over or failed to notice certain possibilities that would have rendered a technically better result.

This is the culmination of years of discussions on different multiple ideas. There is not a better solution.

This is the reason I kept asking for over a year for someone to post a complete set of the requirements behind the decisions. The FAQ is not that complete set, not even in its current form.

It is complete. Ideas that are not relevant to the problem space (like COW) are not included because we they're not relevant.

jridgewell avatar Oct 08 '19 16:10 jridgewell

Because this is how the MOP is designed!

Facts:

  • The MOP has a major foot-gun
  • The MOP can be altered in a way that only affects the foot-gun case
  • The alteration can be carried out in a way that preserves existing code cases

Conclusion: Still not worth it???

The operation you're doing is a set a.x = 1 (aka, SET(base = a, property = x, value = 1). The get operations x.a is already done, nothing can change that.

Again, you're showing you don't fully understand the suggestion. It's not [[Set]], but rather [[Get]] that would be altered, or more precisely OrdinaryGet(O, P, Receiver)

  1. Assert: IsPropertyKey(P) is true.
  2. Let desc be ? O.[GetOwnProperty].
  3. If desc is undefined, then a. Let parent be ? O.[GetPrototypeOf]. b. If parent is null, return undefined. c. Let value be ? parent.[[Get]](P, Receiver). d. Return GetObjectReference(value, Receiver);
  4. If IsDataDescriptor(desc) is true, return desc.[[Value]].
  5. Assert: IsAccessorDescriptor(desc) is true.
  6. Let getter be desc.[[Get]].
  7. If getter is undefined, return undefined.
  8. Return ? Call(getter, Receiver).

An ObjectReference is an exotic object that encapsulates 2 objects, one used as a data source for reading, the other used as a data sink for writing. All modifying Essential Internal Methods other than [[GetPrototypeOf]] are directed toward the data sink. All other operations are directed toward the data source. An object reference is always === to its data source. On retrieval of an Object (any object, array, or function) property from an ObjectReference, the value is likewise wrapped in an ObjectReference with the data sink being the originating ObjectReference. Thus, a write to a nested property of an Object reference causes a cascade, shallow copying each ObjectReference to its data sink until a non-ObjectReference data sink is reached.

GetObjectReceiver checks to see if Receiver is flagged for prototype protection. If it is not, Receiver is returned. Otherwise a new ObjectReference is created, with value as the data source and Receiver as the data sync, and returned.

I'm not sure I can be much clearer than that. If it's still not a good idea or too much of a change, or you still don't quite get it, then oh well. At least I tried. 😄

rdking avatar Oct 08 '19 18:10 rdking

If you want to shoot yourself in the foot, you can already do it with the good-by-default semantics

that's very different from actually having data on the prototype, especially with fields using [[Define]].

There is no downside with the current design's installing data on the instance, and the upside is that everyone will understand it.

There is no upside to installing them on the prototype, and the downside is bugs.

?? this is obviously not true, there are countless downsides and upsides to both mentioned just in 'issues' section of this repo.

You're implying that anything that isn't a function is 'data', and vice versa, however that is not always the case.

In the end, 'class fields' bring very little benefit but have a huge cost. I can understand your position on prototype fields (even though I don't agree with it), but that doesn't mean that the opposite (instance fields) should be added to the language.

a-ejs avatar Oct 10 '19 01:10 a-ejs

@jridgewell Every single class based framework in popular use assigns data to the instance as the best practice. Is there any example that does what you're suggesting? If not, why would we design a new feature that breaks with what we know is the best practice?

FYI: https://v5.canjs.com/doc/can-define/map/map.html

This is the CanJS framework's core means to build observable data models. It declares specs including type converters, getters/setters; serializers; etc. on the prototype chain and then instantiates those as getter/setter on individual instances.

As observability hooks involve a substantial cost to spin up, the initial accessors are lazy and the real observable access logic isn't hooked up until first access of the property. This requires the specs to stick around and be accessible (and referenceable) via the prototype chain, while lazy dummy accessors are already present on the instances.

And ofcourse: having the property specs present on the prototype is necessary to inherit them to further subclasses, rather than the (instance-specific) accessors created from the specs.


This probably is far from the only framework which takes such an approach. (This practice has been around for a long time.)

Also take note that attempting to naively use class __ extends __ on constructor/prototype based 'classes' from such frameworks -- which is bound to happen at one point with developers less versed in the nitty-gritty details of ES classes and class fields -- will lead to awkward bugs and developer headache. On the part of the developer using the framework as well as the developer maintaining such a framework, who will end up having to deal with false positive bug reports and support questions for those cases.

Which actually takes me back to another remark you wrote earlier:

Our responsibility is to design a language that works. Codifying something we know is harmful is the opposite of progress.

In light of what I wrote above, class fields don't fit that mold. They fly counter to the entire notion of the prototype chain and introduce further incompatibility between Constructors and Classes. They widen the schism between new and existing code-bases; libraries and frameworks and further contribute to rising debt and baggage by requiring a strict duality of separated ES class and prototypal code, rather than being able to harmonize the two.

rjgotten avatar Oct 30 '19 13:10 rjgotten

This is the CanJS framework's core means to build observable data models. It declares specs including type converters, getters/setters; serializers; etc. on the prototype chain and then instantiates those as getter/setter on individual instances.

The accessors are stored on prototype, but the data isn't. @rdking is suggesting we store the data on the prototype.

const MyType = DefineMap.extend({ prop: 'string' });

Object.getOwnPropertyDescriptor(MyType.prototype, 'prop');
// => { get: f(), set: f() }

const m = new MyType({ prop: 'foo' });
Object.getOwnPropertyDescriptor(m, '_data');
// => { value: { prop: 'foo' } }

This is very different than OP's proposal.

jridgewell avatar Oct 31 '19 01:10 jridgewell

The accessors are stored on prototype, but the data isn't.

But the accessor specs are data. They're plain objects following a particular duck-typed interface. Regardless of whether data is per-instance 'user' data or that data is prototypal 'framework' data shared by all instances, it's still just data.

And this type of pattern pre-existing in constructor/prototype based code is impossible to hook into with class __ extends __ due to avoiding the prototype. Said avoidance of the prototype 'foot-gun' is as much a practical problem here as it seems to be an ideological one for @rdking.

I'm not asking for prototypal properties to be the default over instance initializer properties though. Instance initializer will probably be by far the more common case moving forward in modern codebases.

But really; would it have been so bad to introduce syntax so you could also have prototype properties? E.g.

class Example {
  foo = "goes to instance";
  proto bar = "goes to prototype";
}

Or any other syntactical means to accomplish the same where instance properties are the preferred path.

rjgotten avatar Oct 31 '19 08:10 rjgotten

But the accessor specs are data.

They are very different. Accessors are immutable functions, the same as regular methods, and we place them on the prototype because sharing them has no downside but has a significant upside (reduced memory).

Accessors are just a fancy way of having this.setFoo(value) and this.getFoo() methods under a unified this.foo property name.

And this type of pattern pre-existing in constructor/prototype based code is impossible to hook into with class __ extends __ due to avoiding the prototype But really; would it have been so bad to introduce syntax so you could also have prototype properties? E.g.

We have accessors in the spec, they've been there since ES6.

Again, notice that this._data isn't an accessor. It's an instance property, as it should be.

jridgewell avatar Oct 31 '19 17:10 jridgewell

@jridgewell By "accessor specs" I am pretty sure @rjgotten means "regular objects on the prototype, which accessors read from".

@rjgotten

But really; would it have been so bad to introduce syntax so you could also have prototype properties? E.g.

Yes, it would have. It's too big of a footgun for the language to encourage it with explicit syntactic support.

It's still easy enough to do it if you really want to:

class Example {
  foo = "goes to instance";
}
Example.prototype.bar = "goes to prototype";

but it is important that prototype-placed data properties be harder to reach for than regular properties or accessors or methods, because they are much more likely to trip people up.

bakkot avatar Oct 31 '19 18:10 bakkot

@bakkot

It's still easy enough to do it if you really want to:

Wrong yet again.

class Example {
  #foo = "goes to instance";
}
Example.prototype.bar() {
   console.log(`(private this).foo = ${this.#foo}`);
}

That dog won't hunt.

rdking avatar Nov 01 '19 03:11 rdking

@rdking If you want to put a method on the prototype, we have syntax for that:

class Example {
  #foo = "goes to instance";
  bar() {
     console.log(`(private this).foo = ${this.#foo}`);
  }
}

It is specifically the case of putting data properties on the prototype which (intentionally) lacks explicit syntactic support.

bakkot avatar Nov 01 '19 03:11 bakkot

Here's the part that's getting to me. Nothing in ES before this point made any real distinction regarding what could be stored in a prototype object. Absolutely nothing. Now, however, due to the fact that some developers are so appalled by the

  • lack of ability to simply probe all available properties
  • foot-gun that occurs if object data is stored on the prototype and callously modified

now suddenly TC39 is taking steps to perform type discrimination in ES, a weakly typed language. The simple fact that functions are 1st class objects in ES means that a function IS data. Try code written like this:

class Example {
  get someProp() { return Object.getOwnPropertyDescriptor(this, someProp).get.someProp; }
  set someProp(v) { Object.getOwnPropertyDescriptor(this, someProp).get.someProp = v; }
}

Perfectly valid code, yes? Data lives in an object on the prototype, yes? Functions are data. The mere existence of static properties is proof of this. It feels as though 2 different philosophies are being preached at the same time with this proposal. I'm lacking the understanding of how T39 can see this and know this, yet still claim it doesn't matter.

rdking avatar Nov 01 '19 03:11 rdking

It's still easy enough to do it if you really want to:

class Example {
  foo = "goes to instance";
}
Example.prototype.bar = "goes to prototype";

At that point, what's the added value of people using class as opposed to whatever class-like inheritance scheme is built-in to the prototype/constructor based framework they're already using? The transition path towards adoption of classes will literally take more effort at that point than just to keep using constructors.

In other words, if the idea behind:

it is important that prototype-placed data properties be harder to reach for than regular properties or accessors or methods, because they are much more likely to trip people up.

-- is that it should discourage complexity of prototypal code over class-based code, then making that transition towards classes harder is going to backfire on you...

rjgotten avatar Nov 01 '19 08:11 rjgotten

At that point, what's the added value of people using class as opposed to whatever class-like inheritance scheme is built-in to the prototype/constructor based framework they're already using?

The added value of class is for the overwhelming majority of users who do not need to put data properties on the prototype.

is that it should discourage complexity of prototypal code over class-based code

It is not to discourage prototypal code. (Classes are prototype-based; they are mostly just declarative sugar.) The point is to discourage specifically putting data properties on the prototype, which is a thing very few libraries or codebases are currently doing and which most that are have lived to regret.

I accept that some people who are currently putting data properties on the prototype may avoid class because it does not have an easy affordance for doing so. It is more important to me that new users of the language not be misled into thinking that's a good idea in typical circumstances.

bakkot avatar Nov 01 '19 16:11 bakkot

Again with the differing perspectives. The class keyword already forces us into strict mode. Why not force in a fix for the prototype problem when data properties are used? It's already simple enough to avoid the prototype if that's what's desired. Making it any easier doesn't solve any problems.

Making it so that prototype properties are safely implemented wouldn't fundamentally alter the language any more than forced strict mode does. Yet doing so would encourage creation of classes that always work properly regardless of what's in them. Between the two choices, I don't understand why you'd choose to break something while trying to get other's to avoid a pitfall, when you can simply fix the pitfall for the one case where people can fall in while you've got the opportunity to do so.

rdking avatar Nov 02 '19 15:11 rdking

Making it so that prototype properties are safely implemented wouldn't fundamentally alter the language any more than forced strict mode does.

It very much would; strict mode alters the behavior of your code, much like syntax sugar; what you suggest would alter how other code interacts with objects your code produces.

ljharb avatar Nov 02 '19 16:11 ljharb

That was true of some prior suggestions of mine, but not of the suggestions I made in this thread. Go back and read the OP.

Now, if declaring a class property placed that property on the prototype in a way that the engine would monitor so that a change would trigger re-creation of the initializer that sets the instance-specific copy, then we'd both have what we want. The rule of law for the class would be the prototype, and every instance would have its own copy.

This would fix many of the issues I have with this proposal without forcing you to give up on anything you want. No more weird breaks around inheritance. That's a good thing, right?

rdking avatar Nov 03 '19 17:11 rdking

@ljharb

what you suggest would alter how other code interacts with objects your code produces.

Btw, class-fields already does this by disturbing the inheritance process. If this is your criteria for exclusion due to fundamental alteration, then this proposal is already something that should be excluded. Any code using this proposal has a potentially negative impact on any other code that uses it. The suggestion in the previous post can remedy this.

rdking avatar Nov 03 '19 17:11 rdking

(Classes are prototype-based; they are mostly just declarative sugar.)

Keyword: mostly

It's that 'mostly' where the opposition to this proposal for class fields is coming from...

Exceptional cases and gotchas originating from maintaining a dual inheritance mechanism with slightly different semantics -- those sound like a bigger long-term foot-gun to me than what's currently being worked around - not solved - by the proposal.

rjgotten avatar Nov 04 '19 10:11 rjgotten

I don't think that they are just "mostly" syntax sugar: they are fully syntax sugar. If classes added a real new functionality to the language, it wouldn't be possible to properly transpile them.

Classes make the language easier to use, but don't add any new possibilities.

nicolo-ribaudo avatar Nov 04 '19 12:11 nicolo-ribaudo