AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

05 Prototypes and classes / Lesson 5 of 5

Property descriptors and the object protocols — toString, valueOf, toJSON, Symbol.toPrimitive

13 min · +10 XP · 2 exercises · 5 questions

Read the text13 min
Exercises0/2
Quiz5 q

Quick answer

What are property descriptors in JavaScript?

A property descriptor is the record of a JavaScript property's attributes: value and writable for a data property, or get and set for an accessor, plus enumerable and configurable. Properties made by assignment or literals have every flag true; Object.defineProperty defaults them to false. Object.getOwnPropertyDescriptor reads one, and Object.freeze and non-enumerable class methods are built on them.

Two more layers sit under ordinary property access. Every property has a descriptor — is it writable, does it show up in loops, can it be deleted or redefined — and Object.defineProperty lets you set those bits, which is how Object.freeze, non-enumerable class methods and read-only constants are built. And the language consults a handful of well-known methods when it needs to turn your object into something else: toString for strings, valueOf for numbers, toJSON for JSON.stringify, Symbol.toPrimitive to take over all of it, Symbol.toStringTag for the [object X] label. Implement them and your class plugs into template literals, +, sorting, JSON and logging without callers doing anything special.

Descriptors

const o = { a: 1 };
Object.getOwnPropertyDescriptor(o, "a");
// { value: 1, writable: true, enumerable: true, configurable: true }

Object.defineProperty(o, "id", { value: 42 });          // defaults are all FALSE: read-only, hidden, permanent
Object.defineProperty(o, "double", { get() { return this.a * 2; }, enumerable: true });
Object.keys(o);              // ["a", "double"] — id is non-enumerable
JSON.stringify(o);           // {"a":1,"double":2} — same rule
o.id = 7;                    // strict mode: TypeError; sloppy: silently ignored
delete o.id;                 // false / TypeError — non-configurable

Three flags on a data property: writable (can the value change), enumerable (does it appear in for…in, Object.keys, spread, JSON.stringify), configurable (can it be deleted or have its descriptor changed; a non-configurable property can still go from writable to non-writable — one-way). Accessor properties replace value/writable with get/set. Properties created by assignment or literals are all-true; defineProperty defaults to all-false, which is the surprise.

Object.defineProperties(obj, {…}) sets several; Object.getOwnPropertyDescriptors(obj) reads all — the correct way to copy accessors, since {...obj} and Object.assign invoke getters and copy the resulting values.

The lock-down trio

  • Object.preventExtensions(o) — no new properties.
  • Object.seal(o) — plus every property non-configurable (no deletes, no redefinition); values still writable.
  • Object.freeze(o) — plus non-writable. Shallow, as module 4 said. Object.isFrozen/isSealed/isExtensible read the state.

Class methods are defined non-enumerable, which is why they do not appear in Object.keys(instance) or in JSON — and why spreading an instance drops its methods.

toString and valueOf

When an object meets a string context (${obj}, String(obj), "" + obj for the string case) or a numeric one (+obj, obj * 2, obj < other), the engine converts it via ToPrimitive with a hint — "string", "number" or "default". Without Symbol.toPrimitive, hint "string" tries toString then valueOf; hints "number" and "default" try valueOf then toString. + uses "default" (so valueOf first — that is why date1 - date2 is a number of milliseconds while ${date} is a readable string); == with a primitive uses "default"; Date is the one built-in whose "default" behaves like "string".

class Money {
  constructor(cents, currency = "USD") { this.cents = cents; this.currency = currency; }
  valueOf() { return this.cents; }                       // numeric contexts
  toString() { return `${(this.cents / 100).toFixed(2)} ${this.currency}`; }
}
const m = new Money(1250);
`${m}`;          // "12.50 USD"  (hint string → toString)
m + 1;           // 1251         (hint default → valueOf)
m > new Money(1000);   // true    (relational → number → valueOf)
[new Money(300), new Money(100)].sort((a, b) => a - b);   // valueOf makes the comparator trivial

Symbol.toPrimitive — one method, all hints

class Temperature {
  constructor(c) { this.c = c; }
  [Symbol.toPrimitive](hint) {
    if (hint === "number") return this.c;
    if (hint === "string") return `${this.c}°C`;
    return this.c;                     // "default": + and ==
  }
}

When present it wins over both toString and valueOf. Returning a non-primitive throws a TypeError. Use it when the two-method dance is not expressive enough (a default that differs from number), otherwise toString/valueOf read more plainly.

toJSON

JSON.stringify calls toJSON() if present and serialises its return value instead of the object — Date uses this to emit an ISO string. Return a plain object or a primitive; return undefined to omit the value entirely. This is also how you expose a class's private state to JSON deliberately (#fields are invisible otherwise), and how you drop derived or sensitive fields.

class User {
  #passwordHash;
  constructor(name, hash) { this.name = name; this.#passwordHash = hash; }
  toJSON() { return { name: this.name, kind: "user" }; }
}
JSON.stringify([new User("ada", "…")]);   // [{"name":"ada","kind":"user"}]

The second argument of JSON.stringify (a replacer function or an allow-list array) and of JSON.parse (a reviver) do the same job from the outside — JSON.parse(text, (k, v) => k === "when" ? new Date(v) : v) is how dates come back to life.

Symbol.toStringTag and inspection

Object.prototype.toString.call(x) returns "[object Array]", "[object Date]", "[object Null]" — the most reliable built-in type test there is, and it reads a class's get [Symbol.toStringTag]() { return "Money"; } to give "[object Money]". Node's console.log and util.inspect also honour a custom [Symbol.for("nodejs.util.inspect.custom")] method for how an instance prints in the REPL. Neither replaces instanceof; they make logs readable.

Common mistakes

  • defineProperty without enumerable: true/writable: true when you wanted a normal property.
  • Copying an object with getters via spread and being surprised the copy has plain values.
  • toString returning a non-string, or valueOf returning an object (falls through to toString — confusing).
  • Relying on + calling toString (it calls valueOf first).
  • Assuming JSON.stringify will see private fields or methods.

Interview angle

  • "What are the property attributes?" writable, enumerable, configurable (plus get/set for accessors); defineProperty defaults them to false.
  • "Object.freeze versus seal versus preventExtensions?" No writes/no deletes/no adds versus no deletes/no adds versus no adds.
  • "How does ${obj} decide what to print?" ToPrimitive with hint string: Symbol.toPrimitive if any, else toString, else valueOf.
  • "How do you customise JSON.stringify for a class?" toJSON() returning the shape to serialise; a replacer from outside.
  • "Why does Object.keys(instance) not list methods?" Class methods are defined non-enumerable on the prototype.

Key takeaways

  • Descriptors: writable/enumerable/configurable; literals default true, defineProperty defaults false; accessors have get/set.
  • preventExtensions ⊂ seal ⊂ freeze, all shallow; class methods are non-enumerable.
  • ToPrimitive: Symbol.toPrimitive(hint) wins; else string hint → toString first, number/default → valueOf first; + and == use default.
  • toJSON decides what JSON.stringify sees; replacers and revivers do it from outside.
  • Symbol.toStringTag labels [object X]; none of these replace instanceof.

Common questions

What is the difference between Object.freeze, Object.seal and Object.preventExtensions?

preventExtensions stops new properties being added; seal also makes every property non-configurable, so none can be deleted or redefined, though values stay writable; freeze also makes them non-writable. All three are shallow — nested objects stay mutable.

When does JavaScript call valueOf and when toString?

It depends on the ToPrimitive hint. A string context such as ${obj} tries toString first; numeric contexts, + and == against a primitive try valueOf first. A Symbol.toPrimitive method, when defined, overrides both, and Date treats the default hint as string.

How do you customise JSON.stringify for a class?

Define a toJSON() method: JSON.stringify calls it and serialises its return value instead of the object, which is how Date emits an ISO string. Return undefined to omit the value. From outside, a replacer passed to JSON.stringify and a reviver passed to JSON.parse do the same job.

Why does Object.keys not list class methods?

Class methods are defined non-enumerable on the prototype, and Object.keys lists only an object's own enumerable properties. For the same reason methods are missing from JSON.stringify output and from a spread copy of an instance.

What is Symbol.toPrimitive in JavaScript?

Symbol.toPrimitive is the method key that takes over converting an object to a primitive. JavaScript calls it with a hint — "string", "number" or "default" — and it wins over both toString and valueOf. It must return a primitive, or a TypeError is thrown.

Mark as readSign in to keep your place.

JavaScript Property Descriptors, valueOf, toString and toJSON

Every JavaScript property has writable, enumerable and configurable flags. How toString, valueOf, Symbol.toPrimitive and toJSON convert objects.

  • Course: JavaScript study plan
  • Module: Prototypes and classes
  • Kind: Lesson
  • Reading time: 13 min
  • Runtime: Node 16

What are property descriptors in JavaScript?

A property descriptor is the record of a JavaScript property's attributes: value and writable for a data property, or get and set for an accessor, plus enumerable and configurable. Properties made by assignment or literals have every flag true; Object.defineProperty defaults them to false. Object.getOwnPropertyDescriptor reads one, and Object.freeze and non-enumerable class methods are built on them.

Lesson

Two more layers sit under ordinary property access. Every property has a descriptor — is it writable, does it show up in loops, can it be deleted or redefined — and Object.defineProperty lets you set those bits, which is how Object.freeze, non-enumerable class methods and read-only constants are built. And the language consults a handful of well-known methods when it needs to turn your object into something else: toString for strings, valueOf for numbers, toJSON for JSON.stringify, Symbol.toPrimitive to take over all of it, Symbol.toStringTag for the [object X] label. Implement them and your class plugs into template literals, +, sorting, JSON and logging without callers doing anything special.

Descriptors

const o = { a: 1 };
Object.getOwnPropertyDescriptor(o, "a");
// { value: 1, writable: true, enumerable: true, configurable: true }

Object.defineProperty(o, "id", { value: 42 });          // defaults are all FALSE: read-only, hidden, permanent
Object.defineProperty(o, "double", { get() { return this.a * 2; }, enumerable: true });
Object.keys(o);              // ["a", "double"] — id is non-enumerable
JSON.stringify(o);           // {"a":1,"double":2} — same rule
o.id = 7;                    // strict mode: TypeError; sloppy: silently ignored
delete o.id;                 // false / TypeError — non-configurable

Three flags on a data property: writable (can the value change), enumerable (does it appear in for…in, Object.keys, spread, JSON.stringify), configurable (can it be deleted or have its descriptor changed; a non-configurable property can still go from writable to non-writable — one-way). Accessor properties replace value/writable with get/set. Properties created by assignment or literals are all-true; defineProperty defaults to all-false, which is the surprise.

Object.defineProperties(obj, {…}) sets several; Object.getOwnPropertyDescriptors(obj) reads all — the correct way to copy accessors, since {...obj} and Object.assign invoke getters and copy the resulting values.

The lock-down trio

  • Object.preventExtensions(o) — no new properties.
  • Object.seal(o) — plus every property non-configurable (no deletes, no redefinition); values still writable.
  • Object.freeze(o) — plus non-writable. Shallow, as module 4 said. Object.isFrozen/isSealed/isExtensible read the state.

Class methods are defined non-enumerable, which is why they do not appear in Object.keys(instance) or in JSON — and why spreading an instance drops its methods.

toString and valueOf

When an object meets a string context (${obj}, String(obj), "" + obj for the string case) or a numeric one (+obj, obj * 2, obj < other), the engine converts it via ToPrimitive with a hint — "string", "number" or "default". Without Symbol.toPrimitive, hint "string" tries toString then valueOf; hints "number" and "default" try valueOf then toString. + uses "default" (so valueOf first — that is why date1 - date2 is a number of milliseconds while ${date} is a readable string); == with a primitive uses "default"; Date is the one built-in whose "default" behaves like "string".

class Money {
  constructor(cents, currency = "USD") { this.cents = cents; this.currency = currency; }
  valueOf() { return this.cents; }                       // numeric contexts
  toString() { return `${(this.cents / 100).toFixed(2)} ${this.currency}`; }
}
const m = new Money(1250);
`${m}`;          // "12.50 USD"  (hint string → toString)
m + 1;           // 1251         (hint default → valueOf)
m > new Money(1000);   // true    (relational → number → valueOf)
[new Money(300), new Money(100)].sort((a, b) => a - b);   // valueOf makes the comparator trivial

Symbol.toPrimitive — one method, all hints

class Temperature {
  constructor(c) { this.c = c; }
  [Symbol.toPrimitive](hint) {
    if (hint === "number") return this.c;
    if (hint === "string") return `${this.c}°C`;
    return this.c;                     // "default": + and ==
  }
}

When present it wins over both toString and valueOf. Returning a non-primitive throws a TypeError. Use it when the two-method dance is not expressive enough (a default that differs from number), otherwise toString/valueOf read more plainly.

toJSON

JSON.stringify calls toJSON() if present and serialises its return value instead of the object — Date uses this to emit an ISO string. Return a plain object or a primitive; return undefined to omit the value entirely. This is also how you expose a class's private state to JSON deliberately (#fields are invisible otherwise), and how you drop derived or sensitive fields.

class User {
  #passwordHash;
  constructor(name, hash) { this.name = name; this.#passwordHash = hash; }
  toJSON() { return { name: this.name, kind: "user" }; }
}
JSON.stringify([new User("ada", "…")]);   // [{"name":"ada","kind":"user"}]

The second argument of JSON.stringify (a replacer function or an allow-list array) and of JSON.parse (a reviver) do the same job from the outside — JSON.parse(text, (k, v) => k === "when" ? new Date(v) : v) is how dates come back to life.

Symbol.toStringTag and inspection

Object.prototype.toString.call(x) returns "[object Array]", "[object Date]", "[object Null]" — the most reliable built-in type test there is, and it reads a class's get [Symbol.toStringTag]() { return "Money"; } to give "[object Money]". Node's console.log and util.inspect also honour a custom [Symbol.for("nodejs.util.inspect.custom")] method for how an instance prints in the REPL. Neither replaces instanceof; they make logs readable.

Common mistakes

  • defineProperty without enumerable: true/writable: true when you wanted a normal property.
  • Copying an object with getters via spread and being surprised the copy has plain values.
  • toString returning a non-string, or valueOf returning an object (falls through to toString — confusing).
  • Relying on + calling toString (it calls valueOf first).
  • Assuming JSON.stringify will see private fields or methods.

Interview angle

  • "What are the property attributes?" writable, enumerable, configurable (plus get/set for accessors); defineProperty defaults them to false.
  • "Object.freeze versus seal versus preventExtensions?" No writes/no deletes/no adds versus no deletes/no adds versus no adds.
  • "How does ${obj} decide what to print?" ToPrimitive with hint string: Symbol.toPrimitive if any, else toString, else valueOf.
  • "How do you customise JSON.stringify for a class?" toJSON() returning the shape to serialise; a replacer from outside.
  • "Why does Object.keys(instance) not list methods?" Class methods are defined non-enumerable on the prototype.

Key takeaways

  • Descriptors: writable/enumerable/configurable; literals default true, defineProperty defaults false; accessors have get/set.
  • preventExtensions ⊂ seal ⊂ freeze, all shallow; class methods are non-enumerable.
  • ToPrimitive: Symbol.toPrimitive(hint) wins; else string hint → toString first, number/default → valueOf first; + and == use default.
  • toJSON decides what JSON.stringify sees; replacers and revivers do it from outside.
  • Symbol.toStringTag labels [object X]; none of these replace instanceof.

Common questions

What is the difference between Object.freeze, Object.seal and Object.preventExtensions?

preventExtensions stops new properties being added; seal also makes every property non-configurable, so none can be deleted or redefined, though values stay writable; freeze also makes them non-writable. All three are shallow — nested objects stay mutable.

When does JavaScript call valueOf and when toString?

It depends on the ToPrimitive hint. A string context such as ${obj} tries toString first; numeric contexts, + and == against a primitive try valueOf first. A Symbol.toPrimitive method, when defined, overrides both, and Date treats the default hint as string.

How do you customise JSON.stringify for a class?

Define a toJSON() method: JSON.stringify calls it and serialises its return value instead of the object, which is how Date emits an ISO string. Return undefined to omit the value. From outside, a replacer passed to JSON.stringify and a reviver passed to JSON.parse do the same job.

Why does Object.keys not list class methods?

Class methods are defined non-enumerable on the prototype, and Object.keys lists only an object's own enumerable properties. For the same reason methods are missing from JSON.stringify output and from a spread copy of an instance.

What is Symbol.toPrimitive in JavaScript?

Symbol.toPrimitive is the method key that takes over converting an object to a primitive. JavaScript calls it with a hint — "string", "number" or "default" — and it wins over both toString and valueOf. It must return a primitive, or a TypeError is thrown.

Exercises

A descriptor report

Parse a JSON record. Define id with Object.defineProperty and only { value: "rec-1" } (so it is read-only, hidden and permanent), and size as an enumerable getter returning the number of keys Object.keys(this) reports. Print keys=, names= (Object.getOwnPropertyNames), json=; then attempt record.id = "other" and delete record.id in try/catch and print writeId=<changed|TypeError> deleteId=<deleted|TypeError> id=<record.id>; then descriptor(id)=<JSON of the descriptor>; then freeze the record, try adding a property, and print frozen=<> add=<added|TypeError> sizeStillComputed=<record.size>.

Example: {"name":"ada","born":1815} →

keys=name,born,size
names=name,born,id,size
json={"name":"ada","born":1815,"size":3}
writeId=TypeError deleteId=TypeError id=rec-1
descriptor(id)={"value":"rec-1","writable":false,"enumerable":false,"configurable":false}
frozen=true add=TypeError sizeStillComputed=3

Money that sorts, adds and serialises

Give Money(cents, currency) a valueOf() returning the cents, a toString() returning <amount 2dp> <currency>, a toJSON() returning { amount, currency } with amount in units, and get [Symbol.toStringTag]() returning "Money". Read cents values, build the objects, and print: template=<a inside a template literal> string=<String(b)>; plus=<a + 1> minus=<a - b> greater=<a > b>; sorted=<copies sorted by (x, y) => x - y, as strings joined by " | ">; json=<JSON.stringify(all)>; tag=<Object.prototype.toString.call(a)> total=<reduce((s, m) => s + m, 0)>.

Example: 1250 999 100 →

template=12.50 USD string=9.99 USD
plus=1251 minus=251 greater=true
sorted=1.00 USD | 9.99 USD | 12.50 USD
json=[{"amount":12.5,"currency":"USD"},{"amount":9.99,"currency":"USD"},{"amount":1,"currency":"USD"}]
tag=[object Money] total=2349

Template literals take the string hint (toString); +, - and > take valueOf.

In this module: Prototypes and classes

← Inheritance — extends, super, overriding and extending built-ins · Checkpoint — Prototypes and classes →