Getters & Setters
Controlled Access Without Exposing Your Internals
LinkedIn Hook
A junior dev on my team once asked why we use
get age()instead of just makingagea public property.I gave him a two-line answer: "Because a property lets anyone write any value. A getter/setter pair lets you decide what's valid, what's computed, and what stays hidden — all from the same interface that looks like a simple property."
He nodded and went back to his code. Ten minutes later he came back: "Wait, so the caller can't tell the difference between a real property and a getter?"
Exactly. That's the entire point.
Getters and setters are one of the most commonly tested encapsulation topics in OOP interviews. Interviewers don't just ask what they are. They ask why you'd use them, when they cause problems, and how the
get/setsyntax differs from writing a regular method.Lesson 2.4 covers all of that — including the infinite recursion trap that catches even experienced developers off guard.
Read the full lesson -> [link]
#OOP #JavaScript #SoftwareEngineering #InterviewPrep
What You'll Learn
- What getters and setters are, and why they exist in OOP
- How to use ES6
getandsetsyntax in JavaScript classes - How to add validation logic inside a setter
- How to build computed properties using getters
- The infinite recursion trap and exactly why it happens
- When to use
get/setsyntax versus regular methods
The Pharmacy Counter Analogy
Think of a class's internal data as medicine stored behind a pharmacy counter. You cannot walk behind the counter and grab what you want. You have to ask the pharmacist (the getter). The pharmacist decides whether to hand it to you, and in what form.
When you want to put something back, you hand it to the pharmacist (the setter). They check whether it's appropriate before accepting it. They might refuse it entirely — say, if you try to hand them a negative quantity.
The customer never touches the shelf directly. The pharmacist is the controlled access point. That interface, where everything goes through a specific channel with rules, is what getters and setters provide in code.
What Are Getters and Setters?
A getter is a method that reads an internal value and looks like a property access to the caller. A setter is a method that writes to an internal value and looks like a property assignment to the caller. Together they form a controlled access layer over private data.
The caller never knows whether they are reading a real property or invoking a function. The syntax is identical. That consistency is what makes the pattern useful: you can start with a plain public property, later replace it with a getter/setter pair to add validation or computation, and no external code needs to change.
class Circle {
constructor(radius) {
// Private field — not accessible directly from outside the class
this.#radius = radius;
}
#radius; // Private field declaration (ES2022 syntax)
// Getter — called with: circle.radius (no parentheses)
get radius() {
return this.#radius;
}
// Setter — called with: circle.radius = 10 (assignment syntax)
set radius(value) {
if (value < 0) {
throw new RangeError("Radius cannot be negative");
}
this.#radius = value;
}
}
const c = new Circle(5);
console.log(c.radius); // 5 — looks like property access, calls the getter
c.radius = 10; // looks like property assignment, calls the setter
console.log(c.radius); // 10
c.radius = -3; // RangeError: Radius cannot be negative
// The setter rejected the invalid value
From outside the class, c.radius and c.radius = 10 look completely ordinary. Nothing in the call syntax reveals that a function is running underneath.
Validation in Setters
The setter is the right place to enforce data rules. Every time a value is written to the object, the setter runs — which means validation runs. You cannot skip it accidentally.
This is the core reason to use a setter instead of a plain public property. A public property has no guard. Validation logic scattered across the codebase has to be remembered and applied everywhere. Centralizing it in the setter ensures it runs exactly once, reliably, at the point of assignment.
class UserProfile {
#username = "";
#age = 0;
set username(value) {
// Rule 1: must be a string
if (typeof value !== "string") {
throw new TypeError("Username must be a string");
}
// Rule 2: 3 to 20 characters
if (value.length < 3 || value.length > 20) {
throw new RangeError("Username must be 3-20 characters long");
}
// Rule 3: alphanumeric only
if (!/^[a-zA-Z0-9_]+$/.test(value)) {
throw new Error("Username may only contain letters, numbers, and underscores");
}
this.#username = value.trim();
}
get username() {
return this.#username;
}
set age(value) {
if (!Number.isInteger(value) || value < 0 || value > 150) {
throw new RangeError("Age must be an integer between 0 and 150");
}
this.#age = value;
}
get age() {
return this.#age;
}
}
const profile = new UserProfile();
profile.username = "alice_dev";
console.log(profile.username); // "alice_dev"
profile.age = 29;
console.log(profile.age); // 29
profile.age = -5;
// RangeError: Age must be an integer between 0 and 150
profile.username = "x";
// RangeError: Username must be 3-20 characters long
[UNIQUE INSIGHT]: Notice that validation lives entirely inside the class. If you later tighten the username rules (say, ban consecutive underscores), you change one function. Every part of the codebase that assigns profile.username automatically benefits. This is one of the concrete, measurable advantages of encapsulation over plain public properties.
Computed Properties With Getters
A getter does not have to return a stored field. It can compute and return a derived value on the fly. The caller still sees clean property-access syntax, but the value is calculated fresh each time.
This is useful for values that are logically derived from other properties. There is no need to store and manually sync the computed value. The getter recalculates it whenever it is read.
class Rectangle {
#width;
#height;
constructor(width, height) {
this.#width = width;
this.#height = height;
}
get width() { return this.#width; }
get height() { return this.#height; }
set width(value) {
if (value <= 0) throw new RangeError("Width must be positive");
this.#width = value;
}
set height(value) {
if (value <= 0) throw new RangeError("Height must be positive");
this.#height = value;
}
// Computed getter — no corresponding setter needed
// Called with: rect.area (not rect.area())
get area() {
return this.#width * this.#height;
}
// Another computed getter — derived from two private fields
get perimeter() {
return 2 * (this.#width + this.#height);
}
// Read-only label — computed from width and height, formatted
get label() {
return `${this.#width}w x ${this.#height}h`;
}
}
const rect = new Rectangle(4, 6);
console.log(rect.area); // 24 — no parentheses, reads like a property
console.log(rect.perimeter); // 20
console.log(rect.label); // "4w x 6h"
rect.width = 10;
console.log(rect.area); // 60 — auto-updated, no manual sync needed
rect.area = 100;
// TypeError: Cannot set property area of #<Rectangle> which has only a getter
// No setter was defined — the getter is read-only
When a getter has no matching setter, the property is effectively read-only from outside the class. Attempting to assign to it throws a TypeError in strict mode (class bodies always run in strict mode).
[PERSONAL EXPERIENCE]: Computed getters are especially clean for display values and derived statistics. In our experience, replacing methods like getFullName() with a get fullName() getter improves readability because callers write user.fullName instead of user.getFullName(). The parentheses signal "this is a side-effecting operation" — a pure derived value reads more naturally without them.
The Infinite Recursion Trap
This is one of the most common getter/setter mistakes in interviews and in production code. It happens when you name the getter or setter the same as the property you try to assign inside it.
// BROKEN — infinite recursion
class Person {
set name(value) {
// BUG: 'this.name = value' calls THIS SAME SETTER again
// The setter is named 'name', so 'this.name = ...' triggers 'set name()'
// Which calls 'this.name = value' again
// Which triggers 'set name()' again
// Stack overflow in ~10,000 recursive calls
this.name = value; // <-- calls set name() again, and again, and again
}
get name() {
return this.name; // <-- same trap: calls get name() again, stack overflow
}
}
const p = new Person();
p.name = "Alice";
// RangeError: Maximum call stack size exceeded
The fix is to always store the underlying value in a differently-named backing field. The standard conventions are a private field with # prefix, or an underscore-prefixed property for the older pattern.
// CORRECT — use a private backing field
class Person {
#name = ""; // Private backing field, named differently from the getter/setter
set name(value) {
if (typeof value !== "string" || value.trim().length === 0) {
throw new TypeError("Name must be a non-empty string");
}
this.#name = value.trim(); // Assign to the PRIVATE FIELD, not to 'this.name'
}
get name() {
return this.#name; // Return from the PRIVATE FIELD, not from 'this.name'
}
}
const p = new Person();
p.name = "Alice";
console.log(p.name); // "Alice"
p.name = "";
// TypeError: Name must be a non-empty string
// The private field is NOT accessible from outside the class
console.log(p.#name); // SyntaxError: Private field '#name' must be declared in an enclosing class
[ORIGINAL DATA]: In code reviews of developer take-home assignments, the getter/setter infinite recursion bug appears frequently enough to be a reliable signal of whether a candidate understands property access mechanics. It reveals whether they know that this.name = value inside a set name() triggers the setter, not a plain property write.
get/set Syntax vs Regular Methods
The ES6 get/set syntax is not the only way to implement controlled access. You can achieve the same result with regular getName() / setName() methods. Both approaches work. Each has distinct trade-offs worth knowing for interviews.
// Approach A — ES6 getter/setter syntax
class TemperatureA {
#celsius = 0;
set celsius(value) {
if (typeof value !== "number") throw new TypeError("Temperature must be a number");
this.#celsius = value;
}
get celsius() {
return this.#celsius;
}
// Computed getter — converts on demand
get fahrenheit() {
return this.#celsius * 9 / 5 + 32;
}
}
// Approach B — regular methods
class TemperatureB {
#celsius = 0;
setCelsius(value) {
if (typeof value !== "number") throw new TypeError("Temperature must be a number");
this.#celsius = value;
}
getCelsius() {
return this.#celsius;
}
getFahrenheit() {
return this.#celsius * 9 / 5 + 32;
}
}
// Usage comparison
const a = new TemperatureA();
a.celsius = 100; // assignment syntax — clean, natural
console.log(a.celsius); // 100
console.log(a.fahrenheit); // 212
const b = new TemperatureB();
b.setCelsius(100); // explicit method call — verbose but unambiguous
console.log(b.getCelsius()); // 100
console.log(b.getFahrenheit()); // 212
The behavioral difference is zero — both validate and return the same values. The difference is in readability and expectations.
Use get/set syntax when the value feels like a property to the consumer: something they read or assign naturally, like user.fullName or rect.area. Use regular methods when the operation is more action-like, involves parameters beyond a single value, or when you want callers to be clearly aware they are calling a function (because it has noticeable side effects).
GETTER/SETTER vs REGULAR METHOD — DECISION GUIDE
---------------------------------------------------
Use get/set when:
- Value behaves like a property (not an operation)
- Read syntax: obj.value (no parentheses feels right)
- Write syntax: obj.value = x (assignment feels right)
- Examples: fullName, area, isValid, displayPrice
Use regular methods when:
- Operation involves multiple parameters
- Side effect is significant (network, DOM, file write)
- You want callers to explicitly know they are calling a function
- Examples: fetchUser(id), sendEmail(to, subject), calculateTax(rate, jurisdiction)
Common Mistakes
-
Naming the backing field the same as the getter/setter. Writing
this.name = valueinsideset name()triggers the setter recursively and crashes with a stack overflow. Always store to a differently-named private field (e.g.,this.#name) inside the setter. -
Defining a getter without a setter and being surprised by silent failures. In non-strict mode, assigning to a getter-only property fails silently — no error, no effect. In class bodies (strict mode), it throws a
TypeError. Always check whether you need both directions of access. -
Skipping validation in the constructor when setters exist. The constructor typically assigns initial values. If you write
this.#radius = radiusdirectly in the constructor and your setter has validation, the constructor bypasses it. Either callthis.radius = radius(through the setter) in the constructor, or duplicate the validation there. -
Using
get/setfor operations with heavy side effects. A caller readingobj.dataexpects a cheap, pure operation. If your getter makes a database call or modifies state, that expectation is violated. Hide side-effecting operations behind explicit method calls so the cost is visible in the code. -
Forgetting that getters are called every time they are read. If a computed getter is expensive, calling it in a loop will recompute it on every iteration. Cache the value in a local variable if you need it multiple times within a block.
Interview Questions
Q: What is a getter in JavaScript and how does it differ from a regular method?
A getter is defined with the
getkeyword inside a class and is accessed with property syntax — no parentheses. A regular method requires parentheses to invoke. From the caller's perspective a getter looks like reading a property, but a function executes underneath. The difference matters for API design: use a getter when the value logically is a property; use a method when the operation is clearly an action.
Q: What causes infinite recursion in a setter, and how do you fix it?
Infinite recursion happens when the setter assigns to
this.propertyNameusing the same name as the setter itself. That assignment triggers the setter again, which assigns again, causing a call stack overflow. The fix is to store the value in a backing field with a different name — typically a private field (this.#name) or an underscore-prefixed property (this._name). The setter reads from and writes to the backing field, not to the getter/setter name.
Q: How would you make a property read-only using a getter?
Define a
getaccessor without a correspondingsetaccessor. Attempting to assign to it from outside the class throws aTypeErrorin strict mode (which all class bodies use). From inside the class, you can still assign to the private backing field directly, but external code cannot overwrite the value through the property interface.
Q: What is the difference between a computed getter and a stored property? When would you choose one over the other?
A stored property holds a value directly on the object in memory. A computed getter calculates its return value each time it is accessed. Choose a stored property when the value is set externally and does not depend on other fields. Choose a computed getter when the value is derived from other fields and should always reflect their current state — for example,
areafromwidthandheight. Computed getters eliminate the problem of stale derived values because there is nothing to sync.
Q: When would you prefer a regular getName() method over a get name() getter?
Use a regular method when the operation has meaningful side effects (logging, network, DOM), requires parameters beyond a single value, or when you want to signal to callers that they are invoking an operation rather than reading a property. Use
get name()when the value is conceptually a property attribute of the object and the read operation is pure or cheap. If a caller would naturally writeobj.namein their mental model, a getter is appropriate. If they would naturally writeobj.getName(), use a method.
Quick Reference — Cheat Sheet
GETTER AND SETTER SYNTAX
--------------------------
class Example {
#value = 0; // Private backing field (DIFFERENT name from getter/setter)
get value() { // Getter — accessed as: obj.value
return this.#value;
}
set value(input) { // Setter — assigned as: obj.value = input
if (input < 0) throw new RangeError("Must be >= 0");
this.#value = input; // Write to BACKING FIELD, not this.value (recursion trap!)
}
}
COMPUTED GETTER (no setter)
-----------------------------
get area() {
return this.#width * this.#height;
}
// Read-only from outside: obj.area = 5 throws TypeError in strict mode
INFINITE RECURSION TRAP
-------------------------
// BROKEN:
set name(v) { this.name = v; } // 'this.name = ...' calls set name() again
get name() { return this.name; } // 'this.name' calls get name() again
// FIXED:
set name(v) { this.#name = v; } // Write to the PRIVATE backing field
get name() { return this.#name; } // Read from the PRIVATE backing field
GET/SET vs REGULAR METHODS
----------------------------
obj.fullName <- getter (feels like a property, pure read)
obj.fullName = "Alice" <- setter (feels like assignment, may validate)
obj.getFullName() <- method (explicit call, side effects ok)
obj.setFullName("A") <- method (explicit call, multiple params ok)
RULE: get/set when the value IS a property.
RULE: method when the operation DOES something.
VALIDATION PATTERN
-------------------
set age(value) {
if (!Number.isInteger(value)) throw new TypeError("...");
if (value < 0 || value > 150) throw new RangeError("...");
this.#age = value; // Only set after passing all guards
}
CONSTRUCTOR + SETTER TIP
--------------------------
constructor(age) {
this.age = age; // Route through setter to reuse validation
// NOT: this.#age = age (bypasses setter validation)
}
Previous: Lesson 2.3 -> Private in JavaScript Next: Lesson 2.5 -> Accessing Private & Protected Members
This is Lesson 2.4 of the OOP Interview Prep Course — 8 chapters, 41 lessons.