Learn JS Series (#33) - Getters and Setters: Computed Properties That Look Like Data
Learn JS Series (#33) - Getters and Setters: Computed Properties That Look Like Data
What will I learn
- You will learn what getters and setters are: functions that behave exactly like ordinary properties;
- the
getandsetsyntax inside object literals AND classes; - how a getter computes a value on read and a setter validates or transforms on assignment;
- the real difference between a data property and an accessor property (the payoff of last episode);
- practical uses -- derived values, validation, lazy/memoized computation -- and the pitfalls (like infinite recursion) to avoid.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Node.js (20+) distribution, or just a modern browser console;
- Episodes 1-32 read, especially objects (ep12) and property descriptors (ep32).
Difficulty
- Beginner
Curriculum (of the Learn JS Series):
- Learn JS Series (#1) - What Is JavaScript, Why It Runs Everywhere, and How to Run It
- Learn JS Series (#2) - Variables and Bindings
- Learn JS Series (#3) - The Primitive Types: number, string, boolean, null, undefined, symbol, bigint
- Learn JS Series (#4) - Operators and Expressions: Arithmetic, Comparison, Logical, and Short-Circuiting
- Learn JS Series (#5) - Strings: Template Literals, Unicode, and the Methods You Actually Use
- Learn JS Series (#6) - Numbers: IEEE 754, Why 0.1 + 0.2 Is Not 0.3, and How to Cope
- Learn JS Series (#7) - Control Flow: if/else, switch, and the Ternary Expression
- Learn JS Series (#8) - Loops: for, while, for...of, for...in, and When to Use Which
- Learn JS Series (#9) - Functions: Declarations, Parameters, Return Values, and Hoisting
- Learn JS Series (#10) - Scope and the Temporal Dead Zone: How JavaScript Finds Your Variables
- Learn JS Series (#11) - Arrays: The Workhorse Data Structure and Its Core Methods
- Learn JS Series (#12) - Objects: Key-Value Data, Dot vs Bracket Access, and Nesting
- Learn JS Series (#13) - Truthiness, Equality, and Coercion: == vs === Done Properly
- Learn JS Series (#14) - Mini Project: A Command-Line Tip Calculator
- Learn JS Series (#15) - First-Class Functions: Passing, Returning, and Storing Functions
- Learn JS Series (#16) - Arrow Functions vs function: Syntax, this, and When Each Wins
- Learn JS Series (#17) - Closures: The Single Most Important Idea in JavaScript
- Learn JS Series (#18) - Higher-Order Functions: Functions That Take or Return Functions
- Learn JS Series (#19) - Callbacks and the Callback Pattern (Before We Reach Promises)
- Learn JS Series (#20) - Default, Rest, and Spread: Flexible Function Signatures
- Learn JS Series (#21) - Destructuring Parameters: Named Arguments the JS Way
- Learn JS Series (#22) - The this Keyword: Five Rules That Explain Every Case
- Learn JS Series (#23) - call, apply, and bind: Controlling this Explicitly
- Learn JS Series (#24) - Recursion: Base Cases, the Call Stack, and Stack Overflows
- Learn JS Series (#25) - IIFEs and the Module Pattern (the Pre-2015 Way to Get Privacy)
- Learn JS Series (#26) - Currying and Partial Application
- Learn JS Series (#27) - Function Composition: Building Pipelines from Small Functions
- Learn JS Series (#28) - Pure Functions and Side Effects: The Foundation of Predictable Code
- Learn JS Series (#29) - Memoization: Trading Memory for Speed with Closures
- Learn JS Series (#30) - Mini Project: A Small Functional Utility Library
- Learn JS Series (#31) - Object Literals, Shorthand, and Computed Property Names
- Learn JS Series (#32) - Property Descriptors: writable, enumerable, configurable
- Learn JS Series (#33) - Getters and Setters: Computed Properties That Look Like Data (this post)
Learn JS Series (#33) - Getters and Setters: Computed Properties That Look Like Data
At the very end of last episode I dangled a promise in front of you. We had spent the whole time on data descriptors -- a value paired with the three flags writable, enumerable, configurable -- and I said there was a second kind of descriptor waiting, one that swaps the plain value for a get/set pair. That second kind is the entire subject of today, and it is, I think, one of the genuinely elegant ideas in the language. It lets you write a property that looks like ordinary data from the outside but is secretly a function on the inside.
That "looks like data, acts like a function" quality is the whole trick, and once you have it in your toolbox you will start seeing it everywhere -- in framework reactivity, in validation layers, in the built-in DOM APIs. So let's earn it properly, step by step, and finish the episode understanding not just the syntax but exactly when reaching for a getter is the right call and when it is quietly the wrong one.
Solutions to Episode 32 Exercises
Exercise 1 - a read-only property:
"use strict";
const math = {};
Object.defineProperty(math, "pi", { value: 3.14159, writable: false, enumerable: true });
math.pi = 3; // ignored in sloppy mode, THROWS under "use strict"
console.log(math.pi); // 3.14159
console.log(Object.getOwnPropertyDescriptor(math, "pi").writable); // false
The insight: writable: false protects the value itself. Assignment silently fails in sloppy mode and throws a TypeError in strict mode -- but the value never changes either way. This is a per-property constant, more surgical than Object.freeze.
Exercise 2 - a hidden property:
const obj = { name: "scipio" };
Object.defineProperty(obj, "token", { value: "abc", enumerable: false });
console.log(Object.keys(obj)); // ["name"]
console.log(JSON.stringify(obj)); // {"name":"scipio"}
console.log(obj.token); // "abc" - still directly accessible
The insight: enumeration (keys, JSON, spread, for...in) all skip non-enumerable properties, yet direct access by name still works perfectly. Hidden-from-loops is NOT the same thing as private.
Exercise 3 - default flags:
const o = {};
Object.defineProperty(o, "x", { value: 1 }); // omitted flags default to false
console.log(Object.getOwnPropertyDescriptor(o, "x"));
// { value: 1, writable: false, enumerable: false, configurable: false }
const p = {};
p.y = 1; // plain assignment
console.log(Object.getOwnPropertyDescriptor(p, "y"));
// { value: 1, writable: true, enumerable: true, configurable: true }
The insight: normal assignment sets all three flags to true, but defineProperty defaults every omitted flag to false -- the exact opposite baseline. That contrast is the trap that catches nearly everyone the first time.
Right, solutions behind us. Now for the most elegant use of the descriptor system: properties that are secretly functions.
What getters and setters actually are
A getter is a function that runs when you read a property. A setter is a function that runs when you assign to a property. But -- and this is the whole point -- from the outside they look and behave exactly like plain data properties. You touch them with ordinary dot syntax, no parentheses, no (), and behind the scenes a function quietly does the work.
Properties defined this way are called accessor properties, as opposed to the ordinary data properties we studied last episode. A data property stores a value. An accessor property stores a getter function, a setter function, or both. That is the fundamental split in the descriptor system, and today we finally use the half we skipped.
Why would you want a property that is secretly a function? Two reasons, mostly. First, to compute a value on the fly from other data, while still presenting it as a clean attribute. Second, to intercept an assignment so you can validate or transform the incoming value before it is stored. Both give you a data-like interface with a function-like brain behind it.
The get and set syntax
Inside an object literal you define a getter with the get keyword and a setter with set, using method-like syntax:
const person = {
firstName: "scipio",
lastName: "the great",
get fullName() {
return `${this.firstName} ${this.lastName}`; // computed on read
},
set fullName(value) {
const parts = value.split(" ");
this.firstName = parts[0];
this.lastName = parts[1];
},
};
console.log(person.fullName); // "scipio the great" - getter runs, note: no ()
person.fullName = "alice smith"; // setter runs, note: no ()
console.log(person.firstName); // "alice"
console.log(person.lastName); // "smith"
Look closely, because there is real magic in the ordinariness here. person.fullName has NO parentheses, yet it runs the getter function and hands back a freshly computed string. And person.fullName = "alice smith" runs the setter, which parses the input and updates two other properties. From the caller's point of view fullName looks and feels like a completely normal field -- but it is entirely computed. That seamless disguise is exactly what makes accessors so useful (and, if you overdo it, occasionally so sneaky -- more on that at the end).
Getters: computed properties that stay in sync
The most common use of a getter is a derived value -- something calculated from other properties that you want to access like a field rather than call like a method. A rectangle's area is the textbook example, and for good reason:
const rectangle = {
width: 4,
height: 3,
get area() {
return this.width * this.height;
},
};
console.log(rectangle.area); // 12 - always current
rectangle.width = 10;
console.log(rectangle.area); // 30 - recomputed automatically
Because area is a getter, it is recomputed every single time you read it, so it can never fall out of sync with width and height. You could of course write a getArea() method instead and call it with parentheses -- functionally identical -- but the getter reads more naturally. rectangle.area says "the area is an attribute of the rectangle", which is conceptually true, in stead of "please go and calculate the area for me". Getters shine precisely for values that are logically attributes but happen to be derived.
Having said that, there is a real design line here: a getter should feel cheap and instantaneous to the reader, because that is what property access implies. We will come back to that expectation, because it matters quite some for keeping your code honest.
Setters: validation and transformation on assignment
A setter intercepts assignment, which makes it the perfect place for validation (reject bad values outright) or transformation (clean and normalize input before storing). Instead of letting any old value be dumped into your object, the setter gets first look:
const account = {
_balance: 0, // convention: a leading underscore means "internal, do not touch directly"
get balance() {
return this._balance;
},
set balance(amount) {
if (typeof amount !== "number" || Number.isNaN(amount) || amount < 0) {
throw new Error("balance must be a non-negative number");
}
this._balance = amount;
},
};
account.balance = 100; // setter validates, then stores
console.log(account.balance); // 100
// account.balance = -50; // throws: "balance must be a non-negative number"
Here balance looks like a plain property, but assigning to it runs a full validation gauntlet first. The actual number lives in _balance -- the leading underscore is a naming convention meaning "this is internal, please leave it alone", not any kind of real enforcement. This pattern -- a public accessor guarding a private-by-convention backing field -- was the standard way to add validation to objects for years, long before real private fields (which we reach later in this phase, with the # syntax) existed. It is still everywhere in older code, so you must be able to read it fluently.
The infinite-recursion trap
There is one classic mistake with setters, and it catches EVERYONE exactly once (myself very much included, years ago). If your setter tries to assign to the same property name it is defining, it calls itself forever -- a stack overflow, exactly the kind we studied back in episode 24:
const bad = {
set name(value) {
// this.name = value; // BUG: assigning to 'name' re-triggers THIS setter -> infinite loop
this._name = value; // FIX: store in a DIFFERENT backing property
},
get name() {
return this._name;
},
};
bad.name = "scipio";
console.log(bad.name); // "scipio"
The rule to burn in: a getter/setter for name must read from and write to a different backing property (conventionally _name), never name itself, or it recurses until the call stack blows up. Keep the public accessor and the private storage under separate names and you will never hit this. Forget it just once and you will get a RangeError: Maximum call stack size exceeded that -- if you did not know this trap existed -- looks utterly baffling.
Getters and setters in classes
So far I have shown object literals, but the get/set syntax works identically inside class bodies, and that is where you will meet it most often in real code. (We cover classes properly a few episodes from now -- for today just read this as "the same idea, dressed for a class"):
class Temperature {
#celsius = 0; // a real private field, previewing what's coming later this phase
get celsius() {
return this.#celsius;
}
set celsius(value) {
if (value < -273.15) throw new RangeError("below absolute zero");
this.#celsius = value;
}
get fahrenheit() {
return this.#celsius * 9 / 5 + 32;
}
set fahrenheit(f) {
this.celsius = (f - 32) * 5 / 9; // reuses the celsius setter's validation
}
}
const t = new Temperature();
t.fahrenheit = 212;
console.log(t.celsius); // 100
console.log(t.fahrenheit); // 212
Notice how celsius and fahrenheit present as two ordinary, writable properties, but under the hood there is only ONE stored value (#celsius) and the pair keep each other consistent. Set one, read the other, and the conversion is automatic and always correct. This is accessor properties doing what they do best: presenting a clean, redundant-looking interface over a single source of truth. And because the fahrenheit setter routes through the celsius setter, the absolute-zero validation is written once and enforced from both directions -- no duplication.
Accessor properties really are descriptors
Under the hood, getters and setters are property descriptors (episode 32), just the accessor flavour: get/set functions where a data property would have value/writable. You can both inspect this and define an accessor directly with Object.defineProperty:
const temp = { celsius: 20 };
Object.defineProperty(temp, "fahrenheit", {
get() { return this.celsius * 9 / 5 + 32; },
set(f) { this.celsius = (f - 32) * 5 / 9; },
enumerable: true,
configurable: true,
});
console.log(temp.fahrenheit); // 68
temp.fahrenheit = 212;
console.log(temp.celsius); // 100
console.log(Object.getOwnPropertyDescriptor(temp, "fahrenheit"));
// { get: [Function: get], set: [Function: set], enumerable: true, configurable: true }
See how the descriptor carries get and set instead of value and writable? THAT is the concrete difference between an accessor property and a data property, and it is why the two are mutually exclusive -- a single property cannot have both a value and a get, JavaScript will throw if you try. This is exactly the unification I promised at the end of last episode: everything you learned about descriptors applies here, we have simply filled in the other half of the picture.
A lazy, self-caching getter
Here is a pattern I genuinely love, because it fuses today's topic with episode 29's memoization. Suppose a getter is expensive -- imagine it does a heavy calculation. You want the convenience of property access, but you do NOT want to pay the cost on every read. The move: compute once, then have the getter quietly redefine itself as a plain data property holding the cached result.
const report = {
rawRows: [/* pretend this is 100000 rows */ 1, 2, 3, 4, 5],
get total() {
console.log("computing total (expensive!)...");
const sum = this.rawRows.reduce((a, b) => a + b, 0);
// Replace THIS accessor with a plain value property of the same name:
Object.defineProperty(this, "total", { value: sum, enumerable: true });
return sum;
},
};
console.log(report.total); // logs "computing..." then 15 (getter runs)
console.log(report.total); // 15 (no log - now a plain value)
The first read runs the getter, does the work, and overwrites total with a normal data property carrying the answer. Every read after that hits the plain value -- no function call, no recomputation. This "lazy getter" trick shows off why accessors are more than syntactic sugar: because they are backed by the descriptor machinery, a property can literally change its own nature at runtime. Nota bene: this only makes sense for values that will not change; if rawRows might be updated later, a self-caching getter would happily hand you a stale answer.
A quick look sideways: how Python does it
Since quite some of you arrived here from the Learn Python Series, the parallel is worth drawing, because Python has almost the exact same feature and it will make the concept stick. In Python you use the @property decorator to turn a method into a read accessor, and a matching @<name>.setter for the write side:
class Account:
def __init__(self):
self._balance = 0
@property
def balance(self): # the getter - accessed as account.balance
return self._balance
@balance.setter
def balance(self, amount): # the setter - runs on account.balance = ...
if not isinstance(amount, (int, float)) or amount < 0:
raise ValueError("balance must be a non-negative number")
self._balance = amount
acc = Account()
acc.balance = 100 # setter runs
print(acc.balance) # 100 - getter runs, no parentheses
The resemblance is striking, right down to the _balance backing-field convention and the "no parentheses on access" behaviour. The underlying idea is identical in both languages: expose an attribute-like interface, keep a function in control behind it. Learn it once here and you recognise its twin the moment you land in Python, Ruby, C#, or Kotlin -- they all offer a version of the same thing, because the need it solves (controlled, computed attributes) is universal.
When to use them -- and when NOT to
Getters and setters are elegant, but elegance is a trap if you reach for it reflexively, so let me be blunt about the judgment involved.
Good uses:
- Derived or computed values that genuinely feel like attributes --
area,fullName,fahrenheit. - Validation or normalization on assignment, guarding a backing field.
- Lazy or memoized computation, as in the self-caching getter above.
- Presenting a stable public interface while the internal representation is free to change.
Cautions, and these matter:
- A getter should be cheap and side-effect-free. People reasonably assume that reading a property is fast and harmless. A getter that fires off a network request, mutates state, or takes 200ms violates that expectation and will bite whoever reads your code later. If it is expensive AND repeated, use the lazy-cache trick; if it has side effects, it should probably be an honest method.
- Do not hide heavy logic behind innocent-looking property access. When an operation is clearly an action rather than an attribute --
user.save(),queue.flush()-- a plain method with visible parentheses is more honest than dressing it up as a property.
Used with taste, getters and setters give you clean, encapsulated, self-consistent interfaces. Used carelessly, they smuggle complexity into places where readers do not expect to find it. The syntax is easy; the discipline of knowing when it earns its place is the actual skill.
Try it yourself
- Create an object
circlewith aradiusdata property and a getterareathat returnsMath.PI * radius * radius. Readarea, then changeradius, and readareaagain to confirm it recomputes each time. - Create an object
userwith a getter/setter pair foremailthat stores the value lowercased (transform on set) in a backing property_email, and rejects any string without an@by throwing (validation). Test one valid assignment and one invalid one, catching the error. - Write a
temperatureobject with acelsiusdata property and afahrenheitgetter/setter that converts both ways. Then, in a comment, explain with a one-line example why a setter forxmust never assign toxitself.
So what did we actually cover?
- A getter runs on property read and a setter runs on property assignment, while both look and behave like ordinary data properties (accessed with NO parentheses).
- Define them with
get name() { }andset name(v) { }in object literals and in class bodies; this creates accessor properties, as opposed to data properties. - Getters are ideal for derived/computed values that feel like attributes (
area,fullName), always recomputed on read so they never go stale. - Setters are ideal for validation and transformation on assignment, typically guarding a private-by-convention backing field like
_balance. - A getter/setter must use a DIFFERENT backing property (e.g.
_name) than the one it defines, or it recurses infinitely into a stack overflow. - Under the hood these are just descriptors with
get/setinstead ofvalue/writable-- a property can even redefine itself, which is how a lazy self-caching getter works. - Keep getters cheap and side-effect-free; when something is really an action, use an honest method with visible parentheses.
Next episode we step into the true heart of Phase 3, and into one of JavaScript's most distinctive (and most misunderstood) features: the mechanism that actually powers object inheritance in this language. Everything we have done with objects so far has been sitting on top of it without you seeing it. Time to look underneath. ;-)