skip to content

A class instance is passed to `JSON.stringify` and the class's getters and methods are missing from the output, while the constructor-assigned fields are there. Explain the enumerability rule behind that, and how you would get a computed value into the JSON.

level: seniorimportance: should knowfreq 42%

answer

  1. only some properties reach the wire
  2. two filters, not one
  3. prototype members are not own members
  4. class bodies install non-enumerable members
  5. a dedicated hook overrides the whole shape

basics

~20 s

JSON.stringify serializes only own enumerable string-keyed properties. Class methods and getters live on the prototype, not the instance, and are non-enumerable, so they are skipped; constructor-assigned fields are own and enumerable. Add a toJSON method to include computed values.

solid answer

~50 s

`JSON.stringify` walks a value's **own enumerable string-keyed** properties — the same set `Object.keys` reports. Fields assigned in the constructor (`this.first = first`) are ordinary own enumerable properties, so they serialize. Methods, getters and setters declared in a class body are installed on **`Class.prototype`**, which makes them not own, and they are installed as **non-enumerable**, so they fail both filters. Symbol-keyed properties and anything added with `Object.defineProperty` without `enumerable: true` are skipped for the same reason. Note the rule is about ownership and enumerability, not about accessors: an accessor defined **directly on the instance** with `enumerable: true` *is* serialized, and the getter runs. The clean fix is a `toJSON()` method on the class — `JSON.stringify` calls it and serializes whatever it returns, so you control the payload explicitly. Mapping to a plain DTO object is the alternative when the JSON shape belongs to a boundary rather than to the class.

code

javascript · 17 lines
javascript
class User {
  constructor(first, last) { this.first = first; this.last = last; }
  get fullName() { return `${this.first} ${this.last}`; }
  greet() { return `hi ${this.first}`; }
}

const u = new User('Ada', 'Lovelace');
console.log(Object.keys(u));                          // ["first", "last"]
console.log(Object.keys(User.prototype));             // []
console.log(Object.getOwnPropertyNames(User.prototype)); // ["constructor","fullName","greet"]
console.log(JSON.stringify(u));   // {"first":"Ada","last":"Lovelace"}

User.prototype.toJSON = function () {
  return { first: this.first, last: this.last, fullName: this.fullName };
};
console.log(JSON.stringify(u));
// {"first":"Ada","last":"Lovelace","fullName":"Ada Lovelace"}

go deeper

for a junior

Know that only the fields assigned to the instance itself end up in JSON, and that methods and getters written in the class body do not — they belong to the class, not to each object.

for a middle

State the filter precisely — own, enumerable, string-keyed — and show that class members fail it on both ownership and enumerability. Explain that an own enumerable accessor does serialize, so accessors are not special-cased.

for a senior

Demonstrate the triage: Object.keys, then Reflect.ownKeys, then the prototype, to separate hidden state from inherited state. Recommend toJSON or explicit DTO mapping and say why a copy made by enumerating keys has the same blind spot.

for a principal

Own the boundary decision — whether the serialized shape is derived from the class or written down independently, and the risk that a new internal field silently leaks into a public payload when serialization is implicit.

## The rule, stated exactly When `JSON.stringify` serializes an object it takes that object's **own, enumerable, string-keyed** properties, in the standard own-key order. Three filters, and a class instance fails two of them for everything declared in the class body. ```js class User { constructor(first, last) { this.first = first; this.last = last; } get fullName() { return `${this.first} ${this.last}`; } greet() { return `hi ${this.first}`; } } const u = new User('Ada', 'Lovelace'); Object.keys(u); // ["first", "last"] JSON.stringify(u); // {"first":"Ada","last":"Lovelace"} ``` - `first` and `last` were created by plain assignment in the constructor: **own** and **enumerable**. They serialize. - `fullName` and `greet` were declared in the class body, so they live on `User.prototype`: **not own**. They also carry `enumerable: false`, so even asking `Object.keys(User.prototype)` returns `[]`. They fail twice. ## The important distinction candidates get wrong The common wrong answer is "JSON.stringify can't serialize getters" or "functions are dropped". Neither is the real rule. An accessor defined on the **instance itself** with `enumerable: true` is serialized, and the getter is invoked to produce the value: ```js const o = {}; Object.defineProperty(o, 'computed', { get: () => 42, enumerable: true }); JSON.stringify(o); // {"computed":42} ``` So accessors are not special-cased at all. What was special about the class case is **where the accessor lives**. Functions *are* dropped — a function-valued own enumerable property is omitted from an object — but that is a separate rule about unsupported value types, not the reason class methods vanish. ## The other invisible categories The same own-and-enumerable filter explains a family of related surprises in one go: - **`Object.defineProperty` defaults.** Every descriptor flag you omit defaults to `false`, so `Object.defineProperty(obj, 'x', { value: 1 })` creates a non-enumerable property. It reads back fine, it just never serializes and never appears in `Object.keys`. - **Symbol keys.** Never string-keyed, therefore never serialized. Useful when you deliberately want state that does not travel. - **Private `#` fields.** Not properties at all, so no enumeration API sees them and JSON never contains them. - **Inherited data.** An object created with `Object.create(defaults)` serializes only its own overrides; the defaults sitting on the prototype are silently absent — a genuinely nasty one when a config object is built that way. ## Diagnosing it in production The reported symptom is usually "the API response is missing a field" or "`{}` came back from an object that clearly has data". The three-step triage: 1. `Object.keys(value)` — if it is empty or short, the data is not own-enumerable. 2. `Reflect.ownKeys(value)` — this adds non-enumerable and symbol keys, which distinguishes "hidden on the object" from "not on the object at all". 3. `Object.getPrototypeOf(value)` — if the data is up the chain, it was inherited, and no serializer will pick it up. The same reasoning applies to structural copying: anything that copies by enumerating own enumerable keys sees exactly the JSON set, so a copy made that way loses hidden and inherited state and freezes getter results into static values. If a value survives `Object.keys` but not your copy, you are looking at a different bug; if it fails both, it is this one. ## Getting the computed value in **`toJSON()`** is the language's designated hook: if the value being serialized has a callable `toJSON`, `JSON.stringify` calls it and serializes the return value instead of the object. It is a method on the class, so it inherits normally and every instance gets it. ```js class User { constructor(first, last) { this.first = first; this.last = last; } get fullName() { return `${this.first} ${this.last}`; } toJSON() { return { first: this.first, last: this.last, fullName: this.fullName }; } } JSON.stringify(new User('Ada', 'Lovelace')); // {"first":"Ada","last":"Lovelace","fullName":"Ada Lovelace"} ``` **Explicit mapping** to a plain DTO is the alternative, and often the better one at a service boundary: the wire shape is then written down in one place instead of being an accident of which fields happened to be assigned in a constructor. It also stops the reverse hazard — a field added to the class silently appearing in a public API response. **Making the value an own enumerable property** (compute it in the constructor, or define it with `enumerable: true`) works too, but you lose laziness and the value can go stale as the object mutates. Reach for it only when the derived value really is fixed at construction time.

  • If a getter is defined directly on the instance with enumerable: true, does JSON.stringify include it?
    Yes. The rule is own-and-enumerable, not data-versus-accessor. `Object.defineProperty(o, 'x', { get: () => 42, enumerable: true })` makes `JSON.stringify(o)` produce `{"x":42}` — the getter is invoked and its return value serialized. The class case fails only because the accessor sits on the prototype and is non-enumerable.
  • How would you keep a field on an object but out of its JSON, without deleting it?
    Three options. Define it with `Object.defineProperty(..., { enumerable: false })` so it reads normally but is never enumerated or serialized. Key it with a symbol, which no string-key API or serializer sees. Or make it a `#` private field, which is not a property at all. A `toJSON` that omits it also works and is the most explicit.
  • An object built with Object.create(defaults) serializes without its defaults. Why, and what would you do?
    The defaults live on the prototype, so they are not own properties and `JSON.stringify` ignores them — it only ever looks at the object itself. Flatten before serializing (build a plain object that merges defaults with overrides), or give the type a `toJSON` that resolves the effective values explicitly.

saying these in an interview costs you the question

  • Says JSON.stringify cannot serialize getters at all
  • Claims the methods are missing because functions are dropped
  • Thinks class methods are own properties of each instance
  • Assumes inherited prototype data is serialized
  • Believes defineProperty properties always serialize

context