What I Learned When Overriding a Frozen Object

Photo by Annie Spratt on Unsplash

When solving a problem that involved a frozen object, I learnt a bit more about the details of JavaScript object property access and some of those details are kinda interesting.

The Problem

Recently, I replaced an ESLint rule that was specific to arrays — no-array-foreach — with a more general rule — no-foreach — that also effected failures for the Set, Map and NodeList types.

The no-foreach rule supports a types option — so the that the types for which it is enforced can be configured by the developer — and I planned to leverage that by deprecating and re-implementing no-array-foreach to use no-foreach internally.

Something like this:

alt (github.com)

Here, context’s options property is overwritten before it’s passed to the base rule’s create function. However, that fails with an error:

alt (github.com)

The property is read-only because ESLint calls Object.freeze on context before passing it the the rule’s create function.

To work around this, it’s necessary to create a new object that includes the options, along with all of the other properties on context. And there are a number of ways that can be done.

Spread Syntax

When the spread syntax is used, all of context’s (own) properties are spread into the new object — contextForBaseRule — along with the specified options, like this:

alt (github.com)

There is a problem with this, though: it breaks the prototype chain. That’s not an issue with the options and report properties — they’re own properties on context — however, context has a bunch of other properties that are on its prototype and, with that implementation, the rule fails with an error:

alt (github.com)

parserOptions is one of the properties that’s on context’s prototype and it’s needed within the rule to retrieve the TypeScript node that corresponds to an ESLint node.

Proxy

It’s possible to use a Proxy to override an object’s property, like this:

alt (github.com)

Here, a get handler is specified and when a property is accessed, it checks the property name. If it’s "options", it returns the options that need to be passed to the base rule. Otherwise, it returns the value of context’s property.

However, that doesn’t work either. It fails with this error:

alt (github.com)

The reason for this is that the invariants outlined in the ECMAScript specification are enforced by the Proxy implementation:

Proxy objects maintain these invariants by means of runtime checks on the result of [handlers] invoked on the [[ProxyHandler]] object.

The get handler’s invariants are:

The value reported for a property must be the same as the value of the corresponding target object property if the target object property is a non-writable, non-configurable own data property.

The value reported for a property must be undefined if the corresponding target object property is a non-configurable own accessor property that has undefined as its [[Get]] attribute.

This is something that I’d run into before, but had forgotten. It’s a small comfort, though, to know that there are limits on the havoc that can be wreaked with proxies.

Object.create

Object.create can be used to create a new object which has context as its prototype, to which the options can then be assigned, like this:

alt (github.com)

However, that won’t work and will fail with this error:

alt (github.com)

This surprised me. I expected the options property to be added to the created object — regardless of the fact that there is an options property on the prototype.

It turns out that that’s not how the language works.

The details are in the ECMAScript specification, but the gist of it is that when an object property is assigned a value, the runtime looks for a property descriptor to determine whether or not the assignment is permitted. First, it looks at the object’s own property descriptors, but if it doesn’t find a descriptor, it then looks at the object’s prototype’s descriptors.

So what’s happening here is:

  • The runtime looks for a property descriptor for the options property on contextWithOption, but it doesn’t find one.
  • It then looks for a property descriptor on context and finds one.
  • However, the property descriptor indicates that the property is not writable, so an error is thrown.

Fortunately, the second parameter to Object.create can be used to add properties to the created object — using property descriptors like those passed to Object.defineProperty — like this:

alt (github.com)

Once that’s done, the rule works:

  • When the base rule accesses the options property, it finds the property on contextForBaseRule — which overrides the options property on context.
  • When the base rule calls the report method, it finds the method on context — which is the prototype for contextForBaseRule.
  • And when the base rule accesses the parserOptions property, it finds the property on context’s prototype.

For something that I’d expected to be straightforward, a surprising number of attempts were needed to get this working. 😅 However, I did learn some things along the way.

RxJS core team member; front-end developer; mentor; speaker; open-source contributor

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store