Object Property Configuration and Proxies - Learning JavaScript (2016)

Learning JavaScript (2016)

Chapter 21. Object Property Configuration and Proxies

Accessor Properties: Getters and Setters

There are two types of object properties: data properties and accessor properties. We’ve already seen both, but the accessor properties have been hidden behind some ES6 syntactic sugar (we called them “dynamic properties” in Chapter 9).

We’re familiar with function properties (or methods); accessor properties are similar except they have two functions—a getter and a setter—and when accessed, they act more like a data property than a function.

Let’s review dynamic properties. Imagine you have a User class, with methods setEmail and getEmail. We opted to use a “get” and “set” method instead of just having a property called email because we want to prevent a user from getting an invalid email address. Our class is very simple (for simplicity, we’ll treat any string with an at sign as a valid email address):

const USER_EMAIL = Symbol();

class User {

setEmail(value) {

if(!/@/.test(value)) throw new Error(`invalid email: ${value}`);

this[USER_EMAIL] = value;

}

getEmail() {

return this[USER_EMAIL];

}

}

In this example, the only thing that’s compelling us to use two methods (instead of a property) is to prevent the USER_EMAIL property from receiving an invalid email address. We’re using a symbol property here to discourage accidental direct access of the property (if we used a string property called email or even _email, it would be easy to carelessly access it directly).

This is a common pattern, and it works well, but it’s slightly more unwieldy than we might like. Here’s an example of using this class:

const u = new User();

u.setEmail("john@doe.com");

console.log(`User email: ${u.getEmail()}`);

While this works, it would be more natural to write:

const u = new User();

u.email = "john@doe.com";

console.log(`User email: ${u.email}`);

Enter accessor properties: they allow us to have the benefits of the former with the natural syntax of the latter. Let’s rewrite our class using accessor properties:

const USER_EMAIL = Symbol();

class User {

set email(value) {

if(!/@/.test(value)) throw new Error(`invalid email: ${value}`);

this[USER_EMAIL] = value;

}

get email() {

return this[USER_EMAIL];

}

}

We’ve provided two distinct functions, but they are bundled into a single property called email. If the property is assigned to, then the setter is called (with the assignment value being passed in as the first argument), and if the property is evaluated, the getter is called.

You can provide a getter without a setter; for example, consider a getter that provides the perimeter of a rectangle:

class Rectangle {

constructor(width, height) {

this.width = width;

this.height = height;

}

get perimeter() {

return this.width*2 + this.height*2;

}

}

We don’t provide a setter for perimeter because there’s no obvious way to infer the rectangle’s width and height from the length of the perimeter; it makes sense for this to be a read-only property.

Likewise, you can provide a setter without a getter, though this is a much less common pattern.

Object Property Attributes

At this point, we have a lot of experience with object properties. We know that they have a key (which can be a string or a symbol) and a value (which can be of any type). We also know that you cannot guarantee the order of properties in an object (the way you can in an array or Map). We know two ways of accessing object properties (member access with dot notation, and computed member access with square brackets). Lastly, we know three ways to create properties in the object literal notation (regular properties with keys that are identifiers, computed property names allowing nonidentifiers and symbols, and method shorthand).

There’s more to know about properties, however. In particular, properties have attributes that control how the properties behave in the context of the object they belong to. Let’s start by creating a property using one of the techniques we know; then we’ll useObject.getOwnPropertyDescriptor to examine its attributes:

const obj = { foo: "bar" };

Object.getOwnPropertyDescriptor(obj, 'foo');

This will return the following:

{ value: "bar", writable: true, enumerable: true, configurable: true }

NOTE

The terms property attributes, property descriptor, and property configuration are used interchangeably; they all refer to the same thing.

This exposes the three attributes of a property:

Writable

Controls whether the value of the property can be changed.

Enumerable

Controls whether the property will be included when the properties of the object are enumerated (with for...in, Object.keys, or the spread operator).

Configurable

Controls whether the property can be deleted from the object or have its attributes modified.

We can control property attributes with Object.defineProperty, which allows you to create new properties or modify existing ones (as long as the property is configurable).

For example, if we want to make the foo property of obj read-only, we can use Object.defineProperty:

Object.defineProperty(obj, 'foo', { writable: false });

Now if we try to assign a value to foo, we get an error:

obj.foo = 3;

// TypeError: Cannot assign to read only property 'foo' of [object Object]

WARNING

Attempting to set a read-only property will only result in an error in strict mode. In nonstrict mode, the assignment will not be successful, but there will be no error.

We can also use Object.defineProperty to add a new property to an object. This is especially useful for attribute properties because, unlike with data properties, there’s no other way to add an accessor property after an object has been created. Let’s add a color property to o (we won’t bother with symbols or validation this time):

Object.defineProperty(obj, 'color', {

get: function() { return this.color; },

set: function(value) { this.color = value; },

});

To create a data property, you provide the value property to Object.defineProperty. We’ll add name and greet properties to obj:

Object.defineProperty(obj, 'name', {

value: 'Cynthia',

});

Object.defineProperty(obj, 'greet', {

value: function() { return `Hello, my name is ${this.name}!`; }

});

One common use of Object.defineProperty is making properties not enumerable in an array. We’ve mentioned before that it’s not wise to use string or symbol properties in an array (because it is contrary to the use of an array), but it can be useful if done carefully and thoughtfully. While the use of for...in or Object.keys on an array is also discouraged (instead prefer for, for...of, or Array.prototype.forEach), you can’t prevent people from doing it. Therefore, if you add non-numeric properties to an array, you should make them non-enumerable in case someone (inadvisably) uses for..in or Object.keys on an array. Here’s an example of adding sum and avg methods to an array:

const arr = [3, 1.5, 9, 2, 5.2];

arr.sum = function() { return this.reduce((a, x) => a+x); }

arr.avg = function() { return this.sum()/this.length; }

Object.defineProperty(arr, 'sum', { enumerable: false });

Object.defineProperty(arr, 'avg', { enumerable: false });

We could also do this in one step per property:

const arr = [3, 1.5, 9, 2, 5.2];

Object.defineProperty(arr, 'sum', {

value: function() { return this.reduce((a, x) => a+x); },

enumerable: false

});

Object.defineProperty(arr, 'avg', {

value: function() { return this.sum()/this.length; },

enumerable: false

});

Lastly, there is also a Object.defineProperties (note the plural) that takes an object that maps property names to property definitions. So we can rewrite the previous example as:

const arr = [3, 1.5, 9, 2, 5.2];

Object.defineProperties(arr,

sum: {

value: function() { return this.reduce((a, x) => a+x); },

enumerable: false

}),

avg: {

value: function() { return this.sum()/this.length; },

enumerable: false

})

);

Protecting Objects: Freezing, Sealing, and Preventing Extension

JavaScript’s flexible nature is very powerful, but it can get you into trouble. Because any code anywhere can normally modify an object in any way it wishes, it’s easy to write code that is unintentionally dangerous or—even worse—intentionally malicious.

JavaScript does provide three mechanisms for preventing unintentional modifications (and making intentional ones more difficult): freezing, sealing, and preventing extension.

Freezing prevents any changes to an object. Once you freeze an object, you cannot:

§ Set the value of properties on the object.

§ Call methods that modify the value of properties on the object.

§ Invoke setters on the object (that modify the value of properties on the object).

§ Add new properties.

§ Add new methods.

§ Change the configuration of existing properties or methods.

In essence, freezing an object makes it immutable. It’s most useful for data-only objects, as freezing an object with methods will render useless any methods that modify the state of the object.

To freeze an object, use Object.freeze (you can tell if an object is frozen by calling Object.isFrozen). For example, imagine you have an object that you use to store immutable information about your program (such as company, version, build ID, and a method to get copyright information):

const appInfo = {

company: 'White Knight Software, Inc.',

version: '1.3.5',

buildId: '0a995448-ead4-4a8b-b050-9c9083279ea2',

// this function only accesses properties, so it won't be

// affected by freezing

copyright() {

return `© ${new Date().getFullYear()}, ${this.company}`;

},

};

Object.freeze(appInfo);

Object.isFrozen(appInfo); // true

appInfo.newProp = 'test';

// TypeError: Can't add property newProp, object is not extensible

delete appInfo.company;

// TypeError: Cannot delete property 'company' of [object Object]

appInfo.company = 'test';

// TypeError: Cannot assign to read-only property 'company' of [object Object]

Object.defineProperty(appInfo, 'company', { enumerable: false });

// TypeError: Cannot redefine property: company

Sealing an object prevents the addition of new properties, or the reconfiguration or removal of existing properties. Sealing can be used when you have an instance of a class, as methods that operate on the object’s properties will still work (as long as they’re not attempting to reconfigure a property). You can seal an object with Object.seal, and tell if an object is sealed by calling Object.isSealed:

class Logger {

constructor(name) {

this.name = name;

this.log = [];

}

add(entry) {

this.log.push({

log: entry,

timestamp: Date.now(),

});

}

}

const log = new Logger("Captain's Log");

Object.seal(log);

Object.isSealed(log); // true

log.name = "Captain's Boring Log"; // OK

log.add("Another boring day at sea...."); // OK

log.newProp = 'test';

// TypeError: Can't add property newProp, object is not extensible

log.name = 'test'; // OK

delete log.name;

// TypeError: Cannot delete property 'name' of [object Object]

Object.defineProperty(log, 'log', { enumerable: false });

// TypeError: Cannot redefine property: log

Finally, the weakest protection, making an object nonextensible, only prevents new properties from being added. Properties can be assigned to, deleted, and reconfigured. Reusing our Logger class, we can demonstrate Object.preventExtensions and Object.isExtensible:

const log2 = new Logger("First Mate's Log");

Object.preventExtensions(log2);

Object.isExtensible(log2); // true

log2.name = "First Mate's Boring Log"; // OK

log2.add("Another boring day at sea...."); // OK

log2.newProp = 'test';

// TypeError: Can't add property newProp, object is not extensible

log2.name = 'test'; // OK

delete log2.name; // OK

Object.defineProperty(log2, 'log',

{ enumerable: false }); // OK

I find that I don’t use Object.preventExtensions very often. If I want to prevent extensions to an object, I typically also want to prevent deletions and reconfiguration, so I usually prefer sealing an object.

Table 21-1 summarizes the protection options.

Action

Normal object

Frozen object

Sealed object

Nonextensible object

Add property

Allowed

Prevented

Prevented

Prevented

Read property

Allowed

Allowed

Allowed

Allowed

Set property value

Allowed

Prevented

Allowed

Allowed

Reconfigure property

Allowed

Prevented

Prevented

Allowed

Delete property

Allowed

Prevented

Prevented

Allowed

Table 21-1. Object protection options

Proxies

New in ES6 are proxies, which provide additional metaprogramming functionality (metaprogramming is the ability for a program to modify itself).

An object proxy essentially has the ability to intercept and (optionally) modify actions on an object. Let’s start with a simple example: modifying property access. We’ll start with a regular object that has a couple of properties:

const coefficients = {

a: 1,

b: 2,

c: 5,

};

Imagine that the properties in this object represent the coefficients in a mathematical equation. We might use it like this:

function evaluate(x, c) {

return c.a + c.b * x + c.c * Math.pow(x, 2);

}

So far, so good…we can now store the coefficients of a quadratic equation in an object and evaluate the equation for any value of x. What if we pass in an object with missing coefficients, though?

const coefficients = {

a: 1,

c: 3,

};

evaluate(5, coefficients); // NaN

We could solve the problem by setting coefficients.b to 0, but proxies offer us a better option. Because proxies can intercept actions against an object, we can make sure that undefined properties return a value of 0. Let’s create a proxy for our coefficients object:

const betterCoefficients = new Proxy(coefficients, {

get(target, key) {

return target[key] || 0;

},

});

WARNING

As I write this, proxies are not supported in Babel. However, they are supported in the current build of Firefox, and these code samples can be tested there.

The first argument to the Proxy constructor is the target, or object that’s being proxied. The second argument is the handler, which specifies the actions to be intercepted. In this case, we’re only intercepting property access, denoted by the get function (this is distinct from a get property accessor: this will work for regular properties and get accessors). The get function takes three arguments (we’re only using the first two): the target, the property key (either a string or a symbol), and the receiver (the proxy itself, or something that derives from it).

In this example, we simply check to see if the key is set on the target; if it’s not, we return the value 0. Go ahead and try it:

betterCoefficients.a; // 1

betterCoefficients.b; // 0

betterCoefficients.c; // 3

betterCoefficients.d; // 0

betterCoefficients.anything; // 0;

We’ve essentially created a proxy for our coefficients object that appears to have an infinite number of properties (all set to 0, except the ones we define)!

We could further modify our proxy to only proxy single lowercase letters:

const betterCoefficients = new Proxy(coefficients, {

get(target, key) {

if(!/^[a-z]$/.test(key)) return target[key];

return target[key] || 0;

},

});

Instead of doing our easy check to see if target[key] is truthy, we could return 0 if it’s anything but a number…I’ll leave that as a reader’s exercise.

Similarly, we can intercept properties (or accessors) being set with the set handler. Let’s consider an example where we have dangerous properties on an object. We want to prevent these properties from being set, and the methods from being called, without an extra step. The extra step we’ll use is a setter called allowDangerousOperations, which you have to set to true before accessing dangerous functionality:

const cook = {

name: "Walt",

redPhosphorus: 100, // dangerous

water: 500, // safe

};

const protectedCook = new Proxy(cook, {

set(target, key, value) {

if(key === 'redPhosphorus') {

if(target.allowDangerousOperations)

return target.redPhosphorus = value;

else

return console.log("Too dangerous!");

}

// all other properties are safe

target[key] = value;

},

});

protectedCook.water = 550; // 550

protectedCook.redPhosphorus = 150; // Too dangerous!

protectedCook.allowDangerousOperations = true;

protectedCook.redPhosphorus = 150; // 150

This only scratches the surface of what you can do with proxies. To learn more, I recommend starting with Axel Rauschmayer’s article “Meta Programming with ECMAScript 6 Proxies” and then reading the MDN documentation.

Conclusion

In this chapter, we’ve pulled back the curtain that hides JavaScript’s object mechanism and gotten a detailed picture of how object properties work, and how we can reconfigure that behavior. We also learned how to protect objects from modification.

Lastly, we learned about an extremely useful new concept in ES6: proxies. Proxies allow powerful metaprogramming techniques, and I suspect we will be seeing some very interesting uses for proxies as ES6 gains popularity.