JAVASCRIPT / PROTOTYPES, CLASSES, AND INHERITANCE
Getters, setters, and computed state in classes
Expose derived values as read-only or validated properties using class getters and setters, and predict how they behave with Object.keys and JSON.
What you will learn
- Turn stored state into derived properties with get, so values can never fall out of sync
- Validate and normalise writes in one place by assigning through a setter
- Predict why prototype accessors are invisible to Object.keys, spread, and JSON.stringify
- Store behind a distinct key so a setter never assigns to itself and recurses
Understanding Getters, setters, and computed state in classes
The get and set keywords in a class body do not create methods you call. They create one accessor property on the prototype whose descriptor holds get and set functions instead of value and writable. Reading t.fahrenheit runs ordinary property lookup, finds that accessor on Thermostat.prototype, and calls the getter with this bound to t, so a function call hides behind field syntax. Nothing is stored under that name; the instance has no slot called fahrenheit at all.
That is the point of computed state. If you store both celsius and fahrenheit you own two facts that must always agree, and every write is an opportunity for them to disagree. Store one fact and derive the rest: a getter recomputes on every read, so its result cannot be stale. The setter is the mirror image, converting an incoming value back into the single stored fact, which makes it the one place to validate, clamp, or normalise, and the reason the constructor should assign through it rather than writing the backing field directly.
Two consequences follow from where accessors live. Because they sit on the prototype and are non-enumerable, derived values are absent from Object.keys, object spread, and JSON.stringify, so a payload that needs them requires an explicit toJSON. And recomputation costs something: a getter that sorts or filters does that work on each read, so hoist it into a local inside a loop, or replace it on the instance with an own data property to cache. A getter declared without a matching setter is genuinely read-only, and assigning to it throws a TypeError in strict mode and in modules.
placeholder
class Thermostat {
constructor(celsius) {
this.celsius = celsius; // goes through the setter, so validation runs
}
get celsius() {
return this._celsius;
}
set celsius(value) {
if (value < -273.15) {
throw new RangeError(`${value}C is below absolute zero`);
}
this._celsius = value;
}
get fahrenheit() {
return this._celsius * 9 / 5 + 32;
}
set fahrenheit(value) {
this.celsius = (value - 32) * 5 / 9;
}
}
const t = new Thermostat(20);
console.log(t.fahrenheit);
t.fahrenheit = 212;
console.log(t.celsius);
console.log(Object.keys(t));
console.log(JSON.stringify(t));
try {
t.celsius = -300;
} catch (err) {
console.log(err.message);
}
console.log(t.celsius);A getter or setter turns a function call into property syntax, so derived values are recomputed on every read instead of stored and kept in sync by hand.
Worked examples
Recursive setters and the backing field
Shows why a setter must not write to the property it defines, and what the working version looks like.
class Broken {
get name() { return this.name; }
set name(value) { this.name = value; }
}
class Fixed {
get name() { return this._name; }
set name(value) { this._name = value.trim(); }
}
try {
new Broken().name = 'Ada';
} catch (err) {
console.log(err.constructor.name);
}
const f = new Fixed();
f.name = ' Ada ';
console.log(`[${f.name}]`);
console.log(Object.getOwnPropertyNames(Fixed.prototype));Example explained
Line 1Broken's setter assigns to this.name, which finds the same setter again, so the first write recurses until the stack overflows; V8 words the message 'Maximum call stack size exceeded'.
Line 2Fixed stores under _name, a different key, so the setter writes a plain data property and the getter reads it back.
Line 3The trim lives in the setter, so each value is normalised once on write instead of at every read site.
Line 4getOwnPropertyNames lists a single name entry: a get and a set with the same name form one accessor property, not two.
Read-only computed values
Inspects the descriptor of a getter-only property and what happens when you assign to it.
'use strict';
class Circle {
constructor(radius) {
this.radius = radius;
}
get diameter() {
return this.radius * 2;
}
}
const c = new Circle(3);
console.log(c.diameter);
c.radius = 5;
console.log(c.diameter);
const d = Object.getOwnPropertyDescriptor(Circle.prototype, 'diameter');
console.log(typeof d.get, d.set, d.enumerable);
try {
c.diameter = 10;
} catch (err) {
console.log(err.constructor.name);
}Example explained
Line 1diameter has no stored slot, so changing radius to 5 changes what the next read returns with no synchronisation step.
Line 2The descriptor carries get and set rather than value and writable, and set is undefined because only a getter was declared.
Line 3enumerable is false for everything declared in a class body, which is why derived values disappear from Object.keys and JSON.stringify.
Line 4Assigning to a getter-only property throws TypeError under 'use strict' and in modules; in a sloppy script the write is silently discarded.
Caching an expensive getter
Replaces a prototype accessor with an own data property so the computation runs only on the first read.
class Report {
constructor(rows) {
this.rows = rows;
}
get total() {
console.log('computing');
const sum = this.rows.reduce((a, b) => a + b, 0);
Object.defineProperty(this, 'total', {
value: sum,
enumerable: true,
configurable: true
});
return sum;
}
}
const r = new Report([1, 2, 3]);
console.log(r.total);
console.log(r.total);
console.log(Object.keys(r));Example explained
Line 1The first read runs the getter, which logs once and reduces this.rows to 6.
Line 2defineProperty installs an own data property named total that shadows the prototype accessor for this instance only, so the second read never reaches the getter.
Line 3Plain this.total = sum could not be used here: the prototype accessor has no setter and class bodies are strict, so the assignment would throw.
Line 4Object.keys now lists total because the cache was declared enumerable; writable defaults to false, so the cached value cannot be overwritten by assignment.
Important notes
A setter must declare exactly one parameter, so set size(w, h) {} is a SyntaxError, and its return value is discarded: obj.size = 5 evaluates to 5 whatever the setter returns.
A leading underscore on the backing field is only a convention and stays fully readable from outside; use a # field when the stored value must actually be unreachable.
Common mistakes
Using the accessor's own name as storage, as in set name(v) { this.name = v }, which makes the setter call itself and throws a stack overflow RangeError on the very first write.
Expecting a getter to show up in JSON.stringify(obj) or {...obj}: class accessors are non-enumerable prototype properties, so derived fields silently vanish from API payloads and shallow copies.
Writing the backing field directly in the constructor while a validating setter exists, which lets new Thermostat(-500) construct an impossible object that later writes would have rejected.
Try it yourself
Change, predict, then run
Write a Note class that stores text and exposes a words getter returning the count of whitespace-separated words, plus a text setter that trims the incoming string. Then log Object.keys(note) and JSON.stringify(note) and explain which of text and words shows up.
Open the JavaScript workspaceCheck your understanding
class Order { constructor(items) { this.items = items; } get count() { return this.items.length; } } — what does JSON.stringify(new Order(['a', 'b'])) produce?
- {"items":["a","b"],"count":2}
- {"items":["a","b"]}
- {"count":2}
- A TypeError, because count has no setter
Show answer
count is a non-enumerable accessor on Order.prototype, and JSON.stringify only walks the object's own enumerable properties, so the getter is never invoked and only items is serialised. The first option is tempting because order.count reads exactly like a data field, but property syntax does not make it an own property; the same getter written in an object literal would be own and enumerable and would appear in the output.