Strong brand checking in JavaScript

I'd like to discuss a pattern of brand checking in JavaScript. Jordan Harband has been asking that each proposal have a way to detect this, but I would prefer that we find a general scheme, rather than have each proposal find a unique solution. I see two paths:

  • Type.isType: The idea here would be that we add more functions similar to Array.isArray, like Map.isMap, etc. (It's up for debate whether these are proxy-transparent or not. My view is that these should not be Proxy-transparent, but Mark Miller has expressed the opposite view.) It's important that Type.isType use internal slots, and not an overridable symbol, for reliability (which is the whole point of this proposal).
  • Restoring the previous Object.prototype.toString semantics. The original value Object.prototype.toString gives a strong way to check the type of built-ins. This was changed in ES6 to use Symbol.toStringTag, which removes the reliability that it previously had, and adds performance cost, while serving no use cases that I'm aware of. Just like TC39 has recently considered backing out Symbol.species semantics, we could consider backing out the Symbol.toStringTag semantics.

Either of these options may be controversial, but I think we would benefit significantly by making a general decision than several ad-hoc ones on a case-by-case basis to support brand checking.

2 Likes

Hi!

Wasn't the idea to make polyfilling new types easier (without overriding Object.prototype.toString)? Of course, polyfilling clashes with the desire for reliability. I don't have a solution for that either.

:+1:

1 Like

To clarify, committee consensus when Symbol.toStringTag was left in ES6 was that everything must have a way to brand-check, even if that was an accessor or method that threw an exception.

I am very much on board with a general proposal for this, so that individual proposals do not have to figure it out each time, and so the web can begin to use it on their own primitives - but the committee has not accepted such an attempt in the past.

While I can safely claim that removing Symbol.toStringTag is not web compatible, changing it (on builtins) from a string data property, to a brand-checking accessor that returns a string, would likely be a web-compatible change, and would satisfy brand-checking needs while serving as a general mechanism moving forward.

My main concern about brand checking APIs is that people will use them when they should be doing a duck type check instead.

1 Like

@ljharb Can you say more about the nature of the web compatibility issue? Is this a matter of Symbol.toStringTag existing, or about how it's used by Object.prototype.toString?

@devsnek Should we ensure that the brand check APIs remain unergonomic?

What I mean is, I believe many people are likely implementing Symbol.toStringTag, including in DOM polyfills, and now relying on Object.prototype.toString output will contain it. Additionally, both core-js and es-shims check for missing Symbol.toStringTag output via calling Object.prototype.toString. There are use cases for it - the only real problem is that they're all string data properties instead of brand-checking accessors.

@devsnek there are a myriad of kinds of checks they can already do ergonomically, and often only one of them is appropriate for the situation. A brand check isn't any less likely to be the appropriate choice than any of the others.

I'm not really in favor of adding explicit brand checking methods, but if we're going to do it, I do have an opinion on whether these are proxy-transparent or not: they should not be.

This is because none of the existing methods which perform brand checks as part of their operation are proxy-transparent: Map.prototype.get.call(new Proxy(new Map, {}), "key") will throw, for example. So it would be very strange for Map.isMap(new Proxy(new Map, {})) to return true.

3 Likes

That's an excellent point and good choice of a guideline @bakkot! I'd specify

A brand checking method should be proxy-transparent IFF the brand's instance methods are proxy-transparent.

Array.isArray(new Proxy([], {})) is true as new Proxy([], {}).slice() works fine, whereas Map.isMap(new Prox(new Map, {})) should be false because new Proxy(new Map, {}).keys() throws.

Hopefully nobody is anxious to repeat the bizarre behavior of Array.isArray punching through proxies, given that the rest of the language already has a different behavior.

1 Like

@ljharb If the uses are all through a few central polyfills, we might be able to look into the use cases and see if it would be compatible to change how it's used (while leaving the symbol in place). That's the plan with Symbol.species. Do you think this approach might work for Symbol.toStringTag?

@bakkot I agree that any brand-checking methods should not be proxy-transparent. Could you explain why you're not really in favor of adding explicit brand-checking methods?

1 Like

If you mean to change how a Object.prototype.toString works, or change that Get of Symbol.toStringTag produced a string, then i don’t; old versions of polyfills are widely deployed and are unlikely to be updated in sufficient time to change the landscape. Changing them to brand-checking getters, however, should be entirely compatible.

A general dislike of adding new reflection mechanisms in the absence of a strong reason to do so, basically. I'd be OK with it given sufficient reason, but if that's been discussed I missed it.

3 Likes

Object.kind(thing) : string

that returns "Object", "Array", etc.

In browsers Object.kind(document.body) shall return "Element", "Comment", "Text", etc. name of native class/entity that is a "driver" of objects of that kind.

The kind is not a class name but really is an entity kind marker.

Each JS implementation has "drivers" already and internal implementations of Object.kind() that are actively used in debugging support for example.

I think it would be best if, whatever mechanism we come up with, it is reasonable to implement a similar API for user-defined types. That's one reason I like the API MyClass.isMyClass(value).

3 Likes

A benefit of boolean based MyClass.isMyClass(value) is that these can be defined in userland TypeScript.

// while proposal is still in user-land...

class NewProposal {
  static isNewProposal(value: unknown): value is NewProposal {
    // implementation left for reader
  } 

  method() {}
}

if (NewProposal.isNewProposal(someValue)) {
   // in here TS gives `someValue` the type `NewProposal`
  someValue.method();
}

Whereas string checks, Brands.get(value) === 'well known string', require native TypeScript support.

if (typeof randomValue === 'string') {
  // TS natively understands the knowledge gained by the string equality
  randomValue.search(/regex/); // safe
}

if (Brands.get(someValue) === 'Well Known Brand') {
  // no current way in userland to 'teach' TS how to use the above
  // to narrow the type of `someValue`
  someValue.method(); // TS error
}
2 Likes

What about creating an internal slot (maybe [[Brand]]) on every function that contains a Symbol. Then on each new instance created from that function, another internal slot is created (maybe [[Kind]]) which is set to the [[Brand]] of the new.target. If there is no new.target, then for native objects, the [[Brand]] of the corresponding native object factory is used. Object.kind(obj) would be used to read the [[Kind]] value. Likewise, Object.brand(fn) would read a function's [[Brand]] value.

I don't know what it would take to make this work across realms other than to say that a given function in a given module should somehow be given the same Symbol as the first said function from said module to receive a [[Brand]] from any realm. In this way, all instances of a given function across all realms share the same [[Brand]].

Since TC39 has already laid down a precedent about Proxy vs internal slots in the "private fields" spec, there's no use in going back on that now and making this Proxy transparent.

Can we discuss the rationale behind brand checking a bit? Just so people like me can be on the same page?

For primitives, we already have typeof (but it has issues when it comes to null). For other objects, we have instanceof (but that has issues when it comes to realms/frames/etc)

So, are we trying to propose a replacement for instanceof that works across realms? Are we also trying to replace many uses of typeof (i.e. add functions such as String.isString())?

Something like @rdking's suggestion sounds like a reasonable solution to this problem. If the engine always put the same symbol into each Array, Map, Set, etc class, then that same symbol would be found across realms, and the engine can also provide something like Object.isOfBrand(myArray, Array). This would be another viable way to take care of the brand-checking for built-in types.

I'm wondering how useful brand checking is for user-defined types. Isn't instanceof good enough for userland code? If I load a user-defined library from two different realms, it might make more sense to not to do a cross-realm brand check, especially if we consider the fact that the ream may be loading a different version of this userland library that has minor, or major changes. Unlike with browser APIs, a userland instance being the same brand as a userland class might not mean it has the properties we expect, because it could be an instance of an older version of the class. Pollyfills are an exception here, where we need to make sure it's possible to polyfill new browser APIs.

You can't depend on instanceOf for branding. It has been total garbage since the beginning. It should have been called "hasPrototype" or something like that. Branding is an assurance that the object you're working with was constructed in a particular way by a particular constructor, meaning it (at least immediately after construction) has all the hidden members required to not cause a problem. In strictly typed languages, this is the same as type checking.

For cases where a different realm loads a different version of the same module, that new version would have a different Symbol. After all, the source code wouldn't be the same. As a unique module, its functions would have unique symbols. I would expect brand checks to fail in that case.

If we say that the primary purpose of brand checking is to ensure that our object was created by a particular constructor (and wasn't created through other means, such as Object.create() using our class's prototype), then aren't we sort of attacking symptoms of an underlying issue, instead of the issue itself? If we don't want to acknowledge instances that were created via Object.create() as valid, then shouldn't we be discussing more ergonomic ways to prevent users from creating instances of our class without using our constructor?

Unless this has already been discussed, and it's been deemed that there's no feasible way to do that in Javascript (I'm not sure that there is), so the next best thing we can do is implement brand checking.

In general, I'm not sure how much branding solves the problem either. All brand checking will do for us is make sure it was constructed properly, it won't provide any guarantees after that. So, all we're doing is providing a way to check if a user has improperly constructed the object, we haven't stopped them from improperly modifying the object after it's been constructed.

To make a long story short, it's about internal slots. If the brand matches, then there is a guarantee that the required internal slots exist. This is something that there is no way to check from ES. For instance, I can create an object that otherwise looks and behaves exactly like an array. It will work in all methods that take an array except for certain methods on Array.prototype which require certain internal slots that are only applied during actual array construction. The same thing is true for class instances with "private fields".