New well-known symbol: Symbol.classType

There have been other proposals to introduce something like this. Most recently Symbol.typeofTag. Since that idea had issues that are hard to get around, what if instead we have a new well-known symbol specifically for the purpose of identifying the constructed type of objects? In lieu of a written explanation, I think the code below will do a better job of explaining what I mean.

Object.defineProperty(Symbol, "classType", { value: Symbol() });

class Object {
   constructor() {
      if (new.target) {
         Object.defineProperty(this, Symbol.classType, { value: new.target });
      }
   }
}

This should be automatic and embedded into the process of creating any ES Object. Just the act of calling a constructor function should cause the object to be stamped with this Symbol. This would resolve issues using instanceof since, unlike the prototype, this value cannot be changed. This would also circumvent limitations in typeof since there would always be a check that can be performed to verify the instantiated type of a given object.

This approach would do nothing for objects created via the factory approach as new.target would not be available. In the case of a class constructor behaving as a factory function, the returned object should be stamped with the new.target value if it isn't already present.

This approach allows for the conclusive identification of the "type" of an object without introducing any expensive or intrusive type mechanics in the engine.

Hi,
can you please elaborate on the problem that this would solve, the use case where it would be needed? Currently you can already access the constructor that was used to construct an object using just obj.constructor if everything is set up normally. You mention that Symbol.classType

but where is an immutable constructor reference necessary? If you mess with the prototype, you usually have good reason to do so.

1 Like

Originally, instanceof was usually enough to tell us whether or not something would work. Then came the ability to modify an object's prototype. This led to duck-typing. Now with private fields, even duck-typing isn't sufficient. Between instanceof and typeof, the problem that needed to be solved has always been "Is the 'type' of this object appropriate for what I'm about to do to it?"

There has always been code that expected an object to be staged a certain way and have specific methods available. However, there has rarely ever been a clean and succinct way of determining that. Only a sealed object could guarantee that the prototype wouldn't change, but that's not often done due to both performance and flexibility reasons. One of the main reasons TypeScript and its ilk exist is due to the limitations of not being able to cleanly identify the type of an object.

With just this one symbol, we can introduce types without introducing a type system. I don't think most of us want to turn ES into a typed language. I'm quite happy that I can ignore types most of the time. However, there are occasions when I need the type of an object to be "well defined".

There’s already Symbol.hasInstance - does that not suffice?

If it did, I wouldn't be proposing this. The weakness of Symbol.hasInstance is 3-fold:

  1. It's just as replaceable as the prototype object of an instance. So it still doesn't give you full assurance that the given object is actually constructed by a given class constructor.
  2. It's not at all accessible if the corresponding class constructor is not available in the current scope.
  3. It is incapable of granting you the constructor of the class on the occasion when that is desirable.

Anything with a symbol is just as mutable and/or forgeable as the constructor property, the [[Prototype]], and Symbol.hasInstance.

Any solution that was more robust than this would be identical to an internal slot or a private field brand check (or any identity-based cache the constructor participated in) - which would have the same issues with Proxy that slots, private fields, and identity do.

3 Likes

It still is "usually enough"! When an object inherits from a certain prototype, we can expect it to implement the methods on that prototype object, either directly or at least in faith (a subtype).

I don't see how that is relevant. Are you saying this is a problem because arbitrary objects could have their prototypes changes to fool your instanceof check (despite not being constructed by constructor you're testing for), or is it that you're afraid that an object's prototype is changed after your instanceof check and thereby breaking your code?

This is totally appropriate for a language like javascript. You don't care how the object was constructed or what prototype it inherits from, all you need is that it implements the right interface. Yes, it's not succinct to make this check, and the lack of an accessible type system won't let you check for method signatures but only existence, but it's still useful.

I don't understand, as private fields are not part of the public interface that duck typing tests for. Notice that in pure OOP, you always interact with object through their methods only, and you do not care how they are implemented internally, so duck typing does usually not test for their structure.

You still haven't completely described what you mean by "well defined". Yes, if you don't trust others to manipulate your object without breaking your contracts, you'll have to seal it. But how does introducing Symbol.classType help with that?

The weaknesses you describe for instanceof would apply to your proposed solution as well:

  1. Everyone can fake an object with {[Symbol.classType]: MyObject, …} without using the new MyObject constructor. (And I'd actually argue that this is desirable)
  2. If the constructor is not available, then you can't test something[Symbol.classType] == MyObject either
  3. instanceof doesn't give you the constructor, but .constructor does. It's already there.

So if you want to identify your objects as your own, you can still create a custom symbol property on them in your constructor and use your approach for checking the type. But I don't see a reason to do that with every object constructed using new.

1 Like

I've experienced both.

Duck-typing isn't enough to ensure that. It works most of the time, but there are still occasions where your API can be fed an object with the desired method names available, yet not the desired functionality. Been there too.

I'm not denying that. I'm merely saying it's still not enough for some cases, and what I'm suggesting will shore up this gap for very little cost.

Don't be confused. The argument isn't about duck-typing in specific, but type identification in general. As has been shown in the many issues in github for the class-fields proposal, a duck-typed object passed to a member function that's expecting the object to contain private fields will throw. There's no way to test for that other than try/catch. There's nothing wrong with that. It does, however, show that duck-type checking an object is insufficient to provide any reasonable assurance that a particular method using private fields will be able to use that object.

What I'm proposing is not simply the Symbol itself, but also the engine modification to make it happen automatically. It boils down to this:

  1. the engine creates an ES Object instance
  2. if new.target is a Function, then define [Symbol.classType] = new.target on the new object.
  3. else define [Symbol.classType] = null on the new object
  4. mark the property non-configurable, non-enumerable, non-writable

The process above happens the instant the engine creates a new ES Object. I'm assuming that the creation of a native object also causes the instantiation of an ES Object. I don't know much about engine internals.

With this, we can know both the name and constructor for any given instance constructed via new. This means the weaknesses you attribute don't exist.

  1. Unlike instanceof, the value is always available and unassailable.
  2. While we wouldn't be able to compare by function, we could still compare by name, as in: obj[Symbol.classType] == "SomeKnownConstructorName". Still not flawless since multiple classes can have the same name, but there's another benefit to shore that up.
  3. Since .constructor is mutable, it is unreliable. Also, since the prototype can be replaced at any time unless the object has been sealed, doubly so.

About that other benefit: since the constructing function is always available via the Symbol, its .prototype can be duck-typed to verify we're working with the desired object. This also gives us the benefit of being able to check and see if the object's prototype object has been replaced as well as the ability to call the methods on the original prototype if need be.

From my standpoint, this is a lot of gain for very little cost and 0 interference with existing code or design practices.

Even if it was immutable, a Proxy could always return any value it wanted at that symbol, and i could also stick it onto any object or function (those are examples of “forging”), which defeats the robustness you’re describing.

Much as private fields is ignoring issues with Proxy, so am I, and under the same kind of argument. A Proxy is designed with the intent to lie about its target. If it couldn't, then the Membrane usage (the primary intended usage for Proxy) wouldn't be possible.

I think you missed something here. Every object, whether created by new or not would have this property added by the engine immediately after the new object is created and (maybe I neglected to mention this part) before any other property can be added. In this way, no, you couldn't just stick it onto any object or function because it would already be there, unconfigurable, and read-only, with an appropriate value.

It could also be that I typed something other than what I intended.... One of those days today.

In that case, what you're describing is not a Symbol, it's an internal slot that all objects have, which would mean it needs either syntax, or an API method, to report the value of it.

Ah, that was presented differently before, but now it's becoming clear why you need this to be built into the engine instead of just sticking a custom symbol property on your special objects.

However, we can still do

Object.assign(Object.setPrototypeOf(new YourObject), MyObject.prototype), myProperties)

to fool code that expects a YourObject, and even if your constructor did seal or freeze the instance, we can fake it through

Reflect.construct(Object, [], YourObject)

which constructs an empty object with the YourObject constructor in the symbol but without calling your code.

Maybe, but I chose doing this with a Symbol because of Proxy and TC39's desire to not modify Proxy to support new features using internal slots without making Proxy work for all internal slots.

Um, no. This scenario is protected against as the YourObject instance is still signed properly, still has all the expected private fields, and still has access to the proper prototype through Symbol.classType.prototype. So it's still perfectly possible to interact with the object as a valid (albeit possibly corrupted) instance of YourObject.

Ok. That one works. the best solution against this that I can think of is to pass the constructor function as a parameter along the internal call chain and use that instead of new.target to set the value of the Symbol.

In that way, there's no dependency on new.target, and this attack becomes ineffective.

The other problem is that SES, encapsulation, and polyfilling requirements all would prevent this from being robust in the way you want. In other words, let's say we added a "Pizza" builtin to ES after this fancyTypeof operator was added - it'd be very important that fancyTypeof PizzaPollyfill returned "Pizza".

I'm not concerned about the robustness of a polyfill. By definition, it cannot be as robust as the in-engine solution. The same is true of private fields, the robustness provided by the in-engine solution is only approximated by things like the Babel implementation.

I've reviewed documentation on SES and found that none of its tenets conflict with the goal or functionality of this proposal. If that were the case, then SES would naturally run afoul of any object created that keeps a reference to its own creating function. If you could elaborate on how you think there would be an issue with SES, I'll address it.

I don't have any clue as to why encapsulation would cause an issue with an object having a public, read-only, non-configurable, non-enumerable, Symbol own property that references the constructor function. Please elaborate.

I'm saying that polyfilling requirements for everything else would mean that this feature, natively, could not be robust in the way you want.

SES works because it includes a membrane implementation, and because it wraps objects in a Proxy, as well as the builtins that would otherwise check internal slots. If you can't use a Proxy, or some other means, to "fake" the tag you're asking about, then it would indeed break SES.

Similarly, if your tag thing landed, and then I was unable to accurately polyfill a different brand new builtin (let alone, all new future builtins) because I was unable to fake the tag, then the ability to polyfill anything would be broken.

This is not an issue. A Proxy is not an ES Object, but rather an exotic object with an identity of its own but no properties of its own. As such, it cannot have a distinct Symbol.classType of its own. There's nothing I'm aware of preventing Proxy from lying about the value of this property. Avoiding interference with Proxy is one of the reasons I opted against an internal slot.

To my knowledge, polyfilling a new built-in object constructor could simply be done using either a class definition or a factory that creates the instance using Reflect.construct. That's one of the benefits of using a regular property over an internal slot. They're easy to work with.

I'm assuming that the 2 other responses were examples of the "everything else" that you mentioned. However, those are not issues. Remember, this is just another property on the object. Use of new or Reflect.construct is all that it takes to set it's value to something other than null. That means there is no risk of some other proposal having a feature that somehow interferes with this value unless it is designed into the proposal to do so.

If you think of a case where I'm mistaken, please point it out so I can address it.

I think we're not communicating clearly :-)

Let's imagine two worlds, in which classType is a function that returns the class type (so we can set aside the specific mechanics for this discussion):

  1. There's a future builtin called "Box". It doesn't exist yet, but your "class type" feature does. I polyfill Box by installing it onto globalThis.Box. What is classType(new Box())?

  2. Much later, there's now an existing builtin called "Box". As such, my polyfill detects that it doesn't need to do anything. What is classType(new Box())?

If the two answers must be different, then classType makes all things impossible to faithfully polyfill, because "it's a polyfill" can be detected via classType(polyfill).

If the two answers can be the same, then in "world 2", anybody could make a class (or other object) at any time for which classType(fakeThing) === classType(new Box()), which makes the feature identically as robust (or not robust) as instanceof.

new Box()[Symbol.classType] === Box where Box === globalThis.Box

new Box()[Symbol.classType] === Box where Box === [native function] Box

The two answers are both the same and different concurrently. Within the running engine context of either scenario, the answer is consistent. That's all that counts. The particular Box that created the instance is the value of the Symbol property. I get the problem you're trying to point out. However, that's not an issue at all as it only matters that the instance knows its constructor during a particular run of the engine.