TIL: Overriding a Frozen Object
October 16, 2020 • 6 minute read
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:
const baseRule = require("./no-foreach");
module.exports = {
meta: {
/* ... */
deprecated: true,
replacedBy: ["no-foreach"]
},
create(context) { context.options = [{ types: ["Array"] }]; return baseRule.create(context); }};
Here, context
’s options
property is overwritten before it’s passed to the base rule’s create
function. However, that fails with an error:
TypeError: Error while loading rule 'no-array-foreach': Cannot assign
to read only property 'options' of object '#<Object>'
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:
const baseRule = require("./no-foreach");
module.exports = {
meta: {
/* ... */
},
create(context) { const contextForBaseRule = { ...context, options: [{ types: ["Array"] }] }; return baseRule.create(contextForBaseRule); }};
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:
Error while loading rule 'no-array-foreach': You have used a rule
which requires parserServices to be generated. You must therefore
provide a value for the "parserOptions.project" property for
@typescript-eslint/parser.
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:
const baseRule = require("./no-foreach");
module.exports = {
meta: {
/* ... */
},
create(context) { const contextForBaseRule = new Proxy(context, { get(target, property, receiver) { if (property === "options") { return [{ types: ["Array"] }]; } return Reflect.get(target, property, receiver); } }); return baseRule.create(contextForBaseRule); }};
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:
TypeError: Error while loading rule 'no-array-foreach': 'get' on
proxy: property 'options' is a read-only and non-configurable data
property on the proxy target but the proxy did not return its actual
value (expected '[object Array]' but got '[object Array]')
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:
const baseRule = require("./no-foreach");
module.exports = {
meta: {
/* ... */
},
create(context) { const contextForBaseRule = Object.create(context); contextForBaseRule.options = [{ types: ["Array"] }]; return baseRule.create(contextForBaseRule); }};
However, that won’t work and will fail with this error:
TypeError: Error while loading rule 'no-array-foreach': Cannot assign
to read only property 'options' of object '#<Object>'
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 oncontextWithOption
, 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:
const baseRule = require("./no-foreach");
module.exports = {
meta: {
/* ... */
},
create(context) { const contextForBaseRule = Object.create(context, { options: { value: [{ types: ["Array"] }], writable: false } }); return baseRule.create(contextForBaseRule); }};
Once that’s done, the rule works:
- When the base rule accesses the
options
property, it finds the property oncontextForBaseRule
— which overrides theoptions
property oncontext
. - When the base rule calls the
report
method, it finds the method oncontext
— which is the prototype forcontextForBaseRule
. - And when the base rule accesses the
parserOptions
property, it finds the property oncontext
’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.