Advanced Topics
Property attributes
Each property of a JavaScript object has three associated attributes that determine the property behavior:
- The
writable
attribute specifies whether or not the value of a property can change. - The
enumerable
attribute specifies whether the property is enumerated by thefor/in
loop andObject.keys()
method. - The
configurable
attribute specifies whether the property can be deleted or whether its attributes can be changed.
Computed (or "accessor") properties have four attributes: get
, set
, enumerable
, and configurable
. To obtain property attributes, use Object.getOwnPropertyDescriptor()
method.
Object.getOwnPropertyDescriptor({ x: 1 }, "x");
// => {value: 1, writable: true, enumerable: true, configurable: true}
Object.getOwnPropertyDescriptor({ get x() {} }, "x");
// => {set: undefined, enumerable: true, configurable: true, get: ƒ}
To set the attributes of a property, call the method Object.defineProperty()
, passing the object to be modified, the name of the property to be created or altered, and the property descriptor object.
let o = {};
Object.defineProperty(o, "x", {
value: 1,
writable: true,
enumerable: false,
configurable: true,
});
o.x; // => 1
Object.keys(o); // => []
If you are creating a new property, then omitted attributes will be false
or undefined
. If you are modifying an existing property, then the omitted attributes are left unchanged.
Object.defineProperty(o, "x", {
writable: false,
});
// o.x = 2; // => TypeError: Cannot assign to read only property
o.x; // => 1
Object.defineProperty(o, "x", {
value: 2,
});
o.x; // => 2
Object.defineProperty(o, "x", {
get() {
return 0;
},
});
o.x; // => 0
To create or modify multiple properties, use the method Object.defineProperties()
with the first argument the object to be modified and the second argument an object that maps the names of the properties to their property descriptors. Both Object.defineProperties()
and Object.defineProperty()
methods return the modified object. The method Object.create()
also accepts an object that maps the names of the properties to their property descriptors as its second optional argument.
let p = Object.defineProperties(
{},
{
x: { value: 3, writable: true, enumerable: true, configurable: true },
y: { value: 4, writable: true, enumerable: true, configurable: true },
r: {
get() {
return Math.sqrt(this.x * this.x + this.y * this.y);
},
enumerable: true,
configurable: true,
},
}
);
p.r; // => 5
Object extensibility
The extensible
attribute of an object specifies whether new properties can be added to the object or not. To determine if an object is extensible, pass it to the Object.isExtensible()
method. To make an object non-extensible, pass it to Object.preventExtensions()
method. If new properties are added to the prototype of non-extensible object, the non-extensible object will still inherit those new properties.
The method Object.seal()
makes an object non-extensible, and it also makes all its own properties non-configurable. To test whether an object is sealed, use Object.isSealed()
method.
The method Object.freeze()
, in addition to making an object non-extensible and all of its own properties non-configurable, also makes all its own properties read-only (computed properties are not affected). To test whether an object is frozen, use Object.isFrozen()
.
Object.preventExtensions()
, Object.seal()
, and Object.freeze()
all return the object that they are passed, which allows you to use them in nested function invocations.
let sealed = Object.seal(
Object.create(Object.freeze({ x: 1 }), { y: { value: 2, writable: true } })
);
Object prototype
An object's prototype
attribute specifies the object from which it inherits its properties. Objects created from object literals use Object.prototype
as their prototype.
Object.getPrototypeOf({}); // => Object.prototype
Object.getPrototypeOf([]); // => Array.prototype
Object.getPrototypeOf(() => {}); // => Function.prototype
Objects created with the new
keyword have their prototype set to the prototype property of their constructor function.
class Animal {
constructor(name) {
this.name = name;
}
}
const dog = new Animal("Dog");
dog instanceof Animal; // => true
Object.getPrototypeOf(dog) === Animal.prototype; // => true
Animal.constructor === Function.constructor; // => true
dog.constructor === Animal; // => true
And objects created with Object.create()
use the first argument to that function as their prototype.
let o1 = { x: 1 };
let o2 = Object.create(o1);
o1.isPrototypeOf(o2); // => true
Object.prototype.isPrototypeOf(o1); // => true
Object.prototype.isPrototypeOf(o2); // => true
You can change the prototype of an object with Object.setPrototypeOf()
method.
let o3 = { y: 2 };
Object.setPrototypeOf(o1, o3);
o1.y; // => 2
let a = [1, 2, 3];
Object.setPrototypeOf(a, o3);
a.push; // => undefined
a.y; // => 2
Well-known symbols
If the right-hand operand of instanceof
operator is an object with [Symbol.hasInstance]()
method, then that method is invoked with the left-hand operand as its argument. The return value, converted to a boolean, becomes the value of instanceof
operator.
let uint8 = {
[Symbol.hasInstance](x) {
return Number.isInteger(x) && x >= 0 && x <= 255;
},
};
128 instanceof uint8; // => true
256 instanceof uint8; // => false
If an object has a property with the name Symbol.toStringTag
, then the method Object.prototype.toString()
uses that property value in its output.
function classof(o) {
return Object.prototype.toString.call(o).slice(8, -1);
}
class Plant {
constructor(name) {
this.name = name;
}
get [Symbol.toStringTag]() {
return "Plant";
}
}
let r = new Plant("Iris");
Plant.prototype.toString(); // => '[object Plant]'
classof(r); // => 'Plant'
classof(null); // => 'Null'
classof(undefined); // => 'Undefined'
classof(1); // => 'Number'
classof(10n); // => 'BigInt'
classof(""); // => 'String'
classof(false); // => 'Boolean'
classof(Symbol()); // => 'Symbol'
classof({}); // => 'Object'
classof([]); // => 'Array'
classof(/./); // => 'RegExp'
classof(() => {}); // => 'Function'
classof(new Map()); // => 'Map'
classof(new Set()); // => 'Set'
classof(new Date()); // => 'Date'
classof(new Error()); // => 'Error'
You can override the default constructor for derived subclasses with the [Symbol.species]()
computed property.
class EZArray extends Array {
static get [Symbol.species]() {
return Array;
}
get first() {
return this[0];
}
get last() {
return this[this.length - 1];
}
}
let ez = new EZArray(1, 2, 3);
let sq = ez.map((x) => x ** 2);
ez.last; // => 3
sq.last; // => undefined
The Symbol.isConcatSpreadable
property is a symbol value that is used to configure the behavior of an object when it is concatenated with another array using the concat()
method. It provides control over whether the object should be spread out (flattened) or treated as a single element in the resulting array.
class MyArray extends Array {
constructor(...args) {
super(...args);
this[Symbol.isConcatSpreadable] = false;
}
}
const arr1 = new MyArray(1, 2);
const arr2 = [3, 4];
const newArr = arr1.concat(arr2);
newArr; // => [[1, 2], 3, 4]
There are several well-known symbols that are called internally when string methods are invoked. Such symbols are Symbol.search
, Symbol.match
, Symbol.replace
, and others.
class Glob {
constructor(glob) {
this.glob = glob;
let pattern = glob.replace("?", "([^/]").replace("*", "([^/]*)");
this.pattern = new RegExp(`^${pattern}$`, "u");
}
toString() {
return this.glob;
}
[Symbol.search](s) {
return s.search(this.pattern);
}
[Symbol.match](s) {
return s.match(this.pattern);
}
[Symbol.replace](s, replacement) {
return s.replace(this.pattern, replacement);
}
}
let pattern = new Glob("docs/*.md");
"docs/README.md".search(pattern); // => 0
"docs/index.html".search(pattern); // => -1
const match = "docs/README.md".match(pattern);
match[0]; // => 'docs/README.md'
match[1]; // => 'README'
match.index; // => 0
"docs/README.md".replace(pattern, "dist/$1.html"); // => 'dist/README.html'
The well-known symbol Symbol.toPrimitive
specifies a method to convert an object to a primitive value. The method should accept one string argument that specifies the preferred type of conversion.
class CustomObject {
constructor(value) {
this.value = value;
}
[Symbol.toPrimitive](hint) {
if (hint === "string") {
return `CustomObject: ${this.value}`;
} else if (hint === "number") {
return this.value;
} else {
return this.value;
}
}
}
const obj = new CustomObject(42);
`${obj}`; // => 'CustomObject: 42'
+obj; // => 42
obj + 10; // => 52
Tagged template literals
Tagged template literals allow you to parse template literals with a tag function using a function invocation syntax. The first argument to the tag function is an array of strings, followed by zero or more additional arguments. The first argument also has a property named raw
which is the same array of string except that the escape sequences there are not interpreted.
function foo(strings, ...values) {
strings; // ['Hi ', '! You have ', ' messages.']
values; // ['Roman', 12]
return 49;
}
const firstName = "Roman";
const num = 12;
foo`Hi ${firstName}! You have ${num} messages.`; // => 49
If the template literal has $n$ interpolated values, then the tag function will be invoked with $n+1$ arguments. The first argument will be an array of $n+1$ strings, and the remaining arguments will be the $n$ interpolated values.
function escapeHTML(strings, ...values) {
return strings.reduce((res, str, idx) => {
let val = values[idx - 1];
let escaped = String(val)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
return res + escaped + str;
});
}
const userInput = '<script>alert("XSS")</script>';
const safeStr = escapeHTML`User input: ${userInput}`;
safeStr; // => 'User input: <script>alert("XSS")</script>'
Another use case for tagged template literals is handling translations.
const lang = "zh";
const zh = {
"Hi {0}! You have {1} messages.": "您好{0}!您有{1}個訊息。",
};
function translate(strings, ...args) {
// Construct the template key
const label = strings.reduce((agg, str, idx) => agg + `{${idx - 1}}` + str);
// Retrieve the translated string or fallback to the original
const template = lang === "zh" ? zh[label] || label : label;
// Replace the placeholders with actual arguments
return template.replace(/\{(\d+)\}/g, (_, idx) => args[idx]);
}
const user = "John";
const messages = 5;
translate`Hi ${user}! You have ${messages} messages.`; // => 您好John!您有5個訊息。
Metaprogramming with proxies
The Proxy
class in JavaScript is a powerful tool for metaprogramming, which is a technique where programs have the ability to treat other programs as their data. This allows developers to dynamically intercept and redefine fundamental operations on objects, such as property access, assignment, enumeration, function invocation, and more.
The Proxy
class enables the creation of proxy objects that wrap a target object and intercept operations performed on it. This is often used in conjunction with the Reflect API, which provides methods for performing these intercepted operations in a default manner. To create a proxy, you provide:
- Target Object: The object that the proxy will wrap.
- Handler Object: An object containing trap methods that intercept operations on the target object.
When an operation is performed on the proxy object, it first checks the handler object for a corresponding trap method. If no trap is defined, the operation defaults to the target object using the Reflect API.
let target = { x: 1, y: 2 };
let handler = {
get(target, property, receiver) {
return property;
},
set(target, property, value, receiver) {
return false;
},
deleteProperty(target, property) {
return false;
},
};
let targetProxy = new Proxy(target, handler);
targetProxy.x; // => 'x'
// delete targetProxy.x; // => TypeError
targetProxy.x; // => 'x'
target.x; // => 1
// targetProxy.z = 3; // => TypeError
targetProxy.z; // => 'z'
target.z; // => undefined
Creating a revocable proxy allows you to isolate third-party libraries by revoking the proxy when you are finished with the library. You create a revocable proxy with Proxy.revocable()
factory function, which returns an object with a proxy and a revoke()
function. When you call the revoke()
function, the proxy immediately stops working.
function accessLibrary() {
return 49;
}
let { proxy, revoke } = Proxy.revocable(accessLibrary, {});
proxy(); // => 49
revoke(); // => undefined
// proxy(); // => TypeError
Another use case for proxies is tracking and logging operations on an object for debugging purposes. The handler methods intercept operations on an object, log the data, and then delegate the operations to the target object.
function loggingProxy(o, objName) {
const handler = {
get(target, property, receiver) {
// Returns the value of the property of target object
// Similar to target[property]
console.log(`Handler get(${objName},${property.toString()})`);
let value = Reflect.get(target, property, receiver);
if (
Reflect.ownKeys(target).includes(property) &&
(typeof value === "object" || typeof value === "function")
) {
// Returns a proxy if property value is an object or function
return loggingProxy(value, `${objName}.${property.toString()}`);
}
return value;
},
set(target, property, value, receiver) {
// Sets the property of target object with value
// Similar to target[property] = value
console.log(`Handler set(${objName},${property.toString()},${value})`);
return Reflect.set(target, property, value, receiver);
},
apply(target, receiver, args) {
// Invokes function target as a method of receiver with args arguments
// Similar to target.apply(reveiver, args)
console.log(`Handler ${objName}(${args})`);
return Reflect.apply(target, receiver, args);
},
construct(target, args, receiver) {
// Invokes the constructor target with args arguments
// Similar to new target(...args)
console.log(`Handler ${objName}(${args})`);
return Reflect.construct(target, args, receiver);
},
};
// Automatically create the rest of handler methods
Reflect.ownKeys(Reflect).forEach((handlerName) => {
if (!(handlerName in handler)) {
handler[handlerName] = function (target, ...args) {
console.log(`Handler ${handlerName}(${objName},${args})`);
return Reflect[handlerName](target, ...args);
};
}
});
return new Proxy(o, handler);
}
let data = [2, 3];
let method = { square: (x) => x * x };
let proxyData = loggingProxy(data, "data");
let proxyMethod = loggingProxy(method, "method");
proxyData.map(method.square); // => [4, 9]
console.log("*".repeat(20));
data.map(proxyMethod.square); // => [4, 9]
console.log("*".repeat(20));
for (let x of proxyData) console.log("Datum", x);