Error "detail"

This is an extremely common misconception, which I'll try to clear up! It factors into what @mmkal was asking about engine performance, and it's also at the heart of my other two recent suggestions, Proposal: Parser Augmentation Mechanism and JSON.equals(x, y). The core of it is this:

ECMAScript engines are not required to implement ECMA-262 to the letter in order to comply with the specification.

If you don't understand the meaning behind this, expand the following for a deep-dive into how engines can comply with the spec without following the spec:

So what does "ECMA-262 compliance" actually mean?

I've phrased this somewhat provocatively, but the reason for that is that it's a really important to understand when trying to make changes to the spec. The ECMAScript specification describes a conceptual, theoretical ECMAScript implementation. It uses Abstract Operations (AOs) to describe what this conceptual engine does, and a naïve engine implementation might simply execute everything as described, in order. The engine might have a single JSObject class which performs all the requisite operations - searching its internal property descriptor map, delegating lookups to its prototype, etc. It would be perfectly ECMA-262 compliant on account of, it would be a literal realization of the spec into code.

It would also be extremely slow and would not be able to run the majority of modern websites.

That's why the spec doesn't mandate exactly what an engine must do at all times. All it mandates is that the observable effects of the engine have to be the same as the observable effects of the conceptual engine. For example, let's say you've got a class definition like this:

class ValueHolder {
  #value;
  get value() {return this.#value;}
  set value(v) {this.#value = v;}
}
const holder = new ValueHolder();

The spec says that, whenever you access holder.value in an expression, you call the getter function, which returns the current value of the private variable. And when you set holder.value to a new value, the spec says that you have to call the setter function, which will set the private variable to the new value.

The engine, however, can look at this and see that both accessors are the simple "expose the private variable" type, so as long as it knows that the value accessors haven't been redefined, it doesn't have to perform an actual function call (which is expensive for CPUs) and it can instead just read or write the private field directly. Because there's no observable way to tell the difference, in this case, between actually calling the accessors or just directly accessing the private field, the engine is allowed to do whichever is faster.

That requires the engine to have both (a) a certain amount of freedom of implementation, and (b) enough contextual information to be certain about what the actual outcome will be. Take the following four functions as example, expanding on the above:

class ValueHolder {
  #value;
  get value() {return this.#value;}
  set value(v) {this.#value = v;}
}
const holder = new ValueHolder();

function getValue1() {
  return holder.value;
}

function getValue2(anyHolder=holder) {
  return anyHolder.value;
}

function getValue3(anyHolder=holder) {
  const propName = "value";
  return anyHolder[propName];
}

function getValue4(anyHolder=holder, propName="value") {
  return anyHolder[propName];
}

Each of these four functions do the exact same thing when called with no arguments: they retrieve the value stored in the holder.#value private field. The conceptual ECMAScript virtual machine would have to take all the same steps: locate the value descriptor on the holder object, call the get accessor, return the value it returns. You might expect a tiny performance difference between the four functions because some of them have to check for missing arguments and populate them, but you'd expect them each to be in roughly the same ballpark. You wouldn't expect something like this:

Test case name Result
Static object, static property getValue1 x 31,738 ops/sec ±1.00% (65 runs sampled)
Dynamic object, static property getValue2 x 2,666 ops/sec ±1.14% (18 runs sampled)
Dynamic object, const property getValue3 x 2,571 ops/sec ±0.95% (62 runs sampled)
Dynamic object, dynamic property getValue4 x 2,276 ops/sec ±0.94% (16 runs sampled)

These are the results I get from running the test on Firefox on my laptop, but you can test your own browser to see what results you get. The benchmark has a little bit of extra code to work as a proper test fixture - in particular, each "op" of the "ops/sec" is actually 100,000 calls to the associated function, so getValue4 is actually called 227,600,000 times per second.

So what's going on here? Well, the engine can look at getValue1 and be absolutely certain exactly what piece of data is getting accessed. Since holder is a const, the engine knows that it's not ever going to reference a different instantiated object, so the only check it has to make is "has this particular object had its value definition changed?" And, assuming it hasn't (which it never does), it can just return the value at one unchanging position in memory.

On the other hand, the getValue2 and getValue3 functions don't know that they'll be called with an instance of the ValueHolder class; all they know is that whatever object they get called with, they'll be accessing the value property. (In getValue3, it's obvious for the engine to see that propName can only ever be that one string, so it doesn't have to change its behavior.) In all likelihood, the engine will have internalized the "value" string at parse time; in other words, it will calculate the hash of the string, compare it with all the other internalized strings to find if something already has that hash, and then just use the shared object it finds if so. Thus, when it comes time to look up the "value" property at function-call-time, it can use the hash it already calculated at parse-time to look it up in the internal property map.

The getValue4 function, on the other hand, has no guarantees. The defaults are only defaults, and that means that there's no point in internalizing the "value" string early; if no one ever calls the function with an absent propName argument, that will have been wasted effort. So, when the function gets called, it has to do all the work, at that point in time, every time.

The TL;DR of this is that you should think about ECMAScript programs the way you think about programs written in, say, C or C++, when compiled by an optimizing compiler. It doesn't have to execute that exact code in that exact order, so long as the outputs are correct.

And, in this case, the engine doesn't have to create a new class constructor and prototype every time it executes that code. Let's look at what information the engine has, after it parses this code:

The engine now knows the following:

  • The thrown value will be an anonymous, nameless subclass of Error
  • It will have two instance fields, foo and bar
  • Those fields will be initialized to 5 and "foo", respectively
  • The Error constructor for the thrown object will be called with the value "Error message"
  • No other instance of this anonymous subclass will ever be created

It doesn't need to create a new class every time the code runs, because the class that gets created has the same functionality every time. In all likelihood, the class will create a "shared class" internal structure that encodes all the above information, allocating two "instance field" slots for the declared fields. When the code gets executed, it can create an instance object with those two slots, pointing to the "shared class" as its implementation.

Now, you may think "but Object.getPrototypeOf(error) and error.constructor need to return different values!" And you're right, they do... if the code ever calls or accesses these. This is why observability is important. The only way for code to observe the return value of Object.getPrototypeOf(error) is for it to call Object.getPrototypeOf(error) - and browsers know that, in modern JS code, almost nobody ever calls getPrototypeOf on an object or accesses the constructor field. Knowing that, they make the assumption that it won't be, and they'll clean up afterwards and do it properly if it turns out that was wrong. This is what's known as "fast path" vs "slow path".

So, the smart thing to do here is just leave the constructor and prototype slots blank when you create the instance (I say "blank" and not "undefined" because this isn't happening at the level of JS code; it's more likely to be a nullptr if anything) and then, if and when the code calls a method that needs to use the value stored there, then the engine can fill them in.

In contrast, when the parser encounters the following code:

It can only be certain of the following:

  • A value will be thrown

That's because it doesn't know, at parse time, whether Object.assign will still have the same value as the Object.assign method we're familiar with. It might assume that it probably is, but it still has to emit the code that checks that, and it has to emit the code that deals with if it isn't - and, if the cost of generating that optimized fast-path is less than the expected benefit of using it, the the engine probably just won't bother. On the other hand, when the engine has guarantees about how the code will be used, then it doesn't have to bother writing the slow path at all.