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/isExtensibleread 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
definePropertywithoutenumerable: true/writable: truewhen you wanted a normal property.- Copying an object with getters via spread and being surprised the copy has plain values.
toStringreturning a non-string, orvalueOfreturning an object (falls through totoString— confusing).- Relying on
+callingtoString(it callsvalueOffirst). - Assuming
JSON.stringifywill see private fields or methods.
Interview angle
- "What are the property attributes?"
writable,enumerable,configurable(plusget/setfor accessors);definePropertydefaults them to false. - "
Object.freezeversussealversuspreventExtensions?" 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.toPrimitiveif any, elsetoString, elsevalueOf. - "How do you customise
JSON.stringifyfor 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,definePropertydefaults false; accessors haveget/set. preventExtensions⊂seal⊂freeze, all shallow; class methods are non-enumerable.- ToPrimitive:
Symbol.toPrimitive(hint)wins; else string hint →toStringfirst, number/default →valueOffirst;+and==use default. toJSONdecides whatJSON.stringifysees; replacers and revivers do it from outside.Symbol.toStringTaglabels[object X]; none of these replaceinstanceof.
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=3Money 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
- The prototype chain — how JavaScript objects inherit
- Constructor functions and what `new` really does
- Class syntax — fields, methods, statics, private and accessors
- Inheritance — extends, super, overriding and extending built-ins
- Property descriptors and the object protocols — toString, valueOf, toJSON, Symbol.toPrimitive (this lesson)
- Checkpoint — Prototypes and classes
← Inheritance — extends, super, overriding and extending built-ins · Checkpoint — Prototypes and classes →