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 the for/in loop and Object.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, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&#39;");

    return res + escaped + str;
  });
}

const userInput = '<script>alert("XSS")</script>';
const safeStr = escapeHTML`User input: ${userInput}`;
safeStr; // => 'User input: &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;'

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:

  1. Target Object: The object that the proxy will wrap.
  2. 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);