Skip to main content

How to Use Property Getters and Setters in JavaScript

JavaScript object properties come in two fundamentally different flavors. So far, you have been working with data properties, which store a value directly. But there is a second kind: accessor properties. These look like regular properties from the outside, but behind the scenes they execute functions when you read or write to them. They are defined using get and set keywords, and they unlock powerful patterns like validation, computed values, lazy evaluation, and backward-compatible API design.

This guide covers everything you need to know about accessor properties, from basic syntax to advanced real-world patterns.

Accessor Properties: get and set

A regular data property simply holds a value:

const user = {
name: "Alice"
};

console.log(user.name); // Reading: returns "Alice"
user.name = "Bob"; // Writing: stores "Bob"
console.log(user.name); // Reading: returns "Bob"

An accessor property does not store a value at all. Instead, it defines a getter function (called when you read the property) and/or a setter function (called when you assign to it).

Basic Syntax in Object Literals

const user = {
firstName: "Alice",
lastName: "Smith",

get fullName() {
return `${this.firstName} ${this.lastName}`;
},

set fullName(value) {
const parts = value.split(" ");
this.firstName = parts[0];
this.lastName = parts[1];
}
};

// Reading triggers the getter
console.log(user.fullName); // "Alice Smith"

// Writing triggers the setter
user.fullName = "Bob Johnson";
console.log(user.firstName); // "Bob"
console.log(user.lastName); // "Johnson"
console.log(user.fullName); // "Bob Johnson"

From the outside, fullName behaves exactly like a regular property. You access it without parentheses, just like user.name. But internally, a function runs every time you read or write to it.

Getter-Only Properties (Read-Only)

You can define a property with only a get and no set. This creates a read-only accessor property.

const circle = {
radius: 5,

get area() {
return Math.PI * this.radius ** 2;
},

get circumference() {
return 2 * Math.PI * this.radius;
}
};

console.log(circle.area); // 78.53981633974483
console.log(circle.circumference); // 31.41592653589793

// Attempting to set a getter-only property
circle.area = 100; // Silent failure in non-strict mode
console.log(circle.area); // 78.53981633974483 (unchanged)

In strict mode, writing to a getter-only property throws a TypeError:

"use strict";

const circle = {
radius: 5,
get area() {
return Math.PI * this.radius ** 2;
}
};

circle.area = 100; // TypeError: Cannot set property area of #<Object> which has only a getter

Setter-Only Properties

While less common, you can define a property with only a set. Reading it returns undefined.

const logger = {
_logs: [],

set message(msg) {
this._logs.push(`[${new Date().toISOString()}] ${msg}`);
}
};

logger.message = "App started";
logger.message = "User logged in";

console.log(logger.message); // undefined (no getter defined)
console.log(logger._logs);
// ["[2024-01-15T...] App started", "[2024-01-15T...] User logged in"]
tip

Setter-only properties are rarely useful on their own. In most cases, you want both a getter and a setter working together.

Defining Accessors with Object.defineProperty()

Besides the literal syntax, you can add accessor properties to existing objects using Object.defineProperty():

const user = {
firstName: "Alice",
lastName: "Smith"
};

Object.defineProperty(user, "fullName", {
get() {
return `${this.firstName} ${this.lastName}`;
},
set(value) {
const [first, last] = value.split(" ");
this.firstName = first;
this.lastName = last;
},
enumerable: true,
configurable: true
});

console.log(user.fullName); // "Alice Smith"
user.fullName = "Bob Johnson";
console.log(user.firstName); // "Bob"

This approach is essential when you need to add accessors to objects you did not create, or when you need to set enumerable or configurable flags explicitly.

Accessor Descriptors vs. Data Descriptors

Every property in JavaScript has a descriptor, but accessor properties and data properties have fundamentally different descriptor shapes. They are mutually exclusive.

Data Property Descriptor

A data property descriptor has these fields:

FieldDescription
valueThe stored value
writableWhether the value can be changed
enumerableWhether it appears in enumeration
configurableWhether it can be reconfigured or deleted
const obj = { name: "Alice" };
console.log(Object.getOwnPropertyDescriptor(obj, "name"));
// { value: "Alice", writable: true, enumerable: true, configurable: true }

Accessor Property Descriptor

An accessor property descriptor has these fields:

FieldDescription
getFunction called when reading the property
setFunction called when writing to the property
enumerableWhether it appears in enumeration
configurableWhether it can be reconfigured or deleted
const obj = {
get name() { return "Alice"; }
};

console.log(Object.getOwnPropertyDescriptor(obj, "name"));
// { get: [Function: get name], set: undefined, enumerable: true, configurable: true }

Notice there is no value and no writable in an accessor descriptor.

You Cannot Mix Them

A single property cannot be both a data property and an accessor property. If you try to define a descriptor with both value/writable and get/set, JavaScript throws a TypeError:

// This will throw an error
try {
Object.defineProperty({}, "broken", {
value: 42,
get() { return 42; }
});
} catch (e) {
console.log(e.message);
// Invalid property descriptor. Cannot both specify accessors and a value or writable attribute
}
caution

You must choose one or the other. A property is either a data property (has value and writable) or an accessor property (has get and/or set). Never both.

Converting Between Data and Accessor Properties

You can convert an existing data property into an accessor property by redefining it (as long as configurable is true):

const user = { _name: "Alice" };

// Currently a data property
Object.defineProperty(user, "name", {
value: "Alice",
writable: true,
enumerable: true,
configurable: true
});

// Convert to accessor property
Object.defineProperty(user, "name", {
get() { return this._name; },
set(value) { this._name = value; },
enumerable: true,
configurable: true
});

console.log(Object.getOwnPropertyDescriptor(user, "name"));
// { get: [Function: get], set: [Function: set], enumerable: true, configurable: true }

Using Getters and Setters for Validation and Computed Properties

One of the most practical uses of accessor properties is input validation. Instead of trusting that consumers of your object will always provide correct values, you enforce rules right at the property level.

Validation Example: Age Property

Without validation (the problem):

const user = { name: "Alice", age: 30 };

user.age = -5; // No error, but makes no sense
user.age = "not a number"; // No error, but will break things later
console.log(user.age); // "not a number"

With getter/setter validation (the solution):

const user = {
name: "Alice",
_age: 30,

get age() {
return this._age;
},

set age(value) {
if (typeof value !== "number") {
throw new TypeError(`Age must be a number, got ${typeof value}`);
}
if (value < 0 || value > 150) {
throw new RangeError(`Age must be between 0 and 150, got ${value}`);
}
if (!Number.isInteger(value)) {
throw new TypeError(`Age must be an integer, got ${value}`);
}
this._age = value;
}
};

console.log(user.age); // 30

user.age = 25;
console.log(user.age); // 25

try {
user.age = -5;
} catch (e) {
console.log(e.message); // "Age must be between 0 and 150, got -5"
}

try {
user.age = "thirty";
} catch (e) {
console.log(e.message); // "Age must be a number, got string"
}

console.log(user.age); // 25 (unchanged after failed validations)
info

The convention of prefixing "internal" properties with an underscore (_age) is just a naming convention. It signals to other developers that the property should not be accessed directly. For true privacy, use private class fields (#age) covered in the Classes module.

Computed Properties Example: Temperature Converter

Accessor properties are perfect for computed values that derive from other properties:

const temperature = {
_celsius: 0,

get celsius() {
return this._celsius;
},

set celsius(value) {
if (typeof value !== "number" || Number.isNaN(value)) {
throw new TypeError("Temperature must be a valid number");
}
this._celsius = value;
},

get fahrenheit() {
return this._celsius * 9 / 5 + 32;
},

set fahrenheit(value) {
if (typeof value !== "number" || Number.isNaN(value)) {
throw new TypeError("Temperature must be a valid number");
}
this._celsius = (value - 32) * 5 / 9;
},

get kelvin() {
return this._celsius + 273.15;
},

set kelvin(value) {
if (typeof value !== "number" || Number.isNaN(value)) {
throw new TypeError("Temperature must be a valid number");
}
this._celsius = value - 273.15;
}
};

temperature.celsius = 100;
console.log(temperature.fahrenheit); // 212
console.log(temperature.kelvin); // 373.15

temperature.fahrenheit = 32;
console.log(temperature.celsius); // 0
console.log(temperature.kelvin); // 273.15

temperature.kelvin = 0;
console.log(temperature.celsius); // -273.15
console.log(temperature.fahrenheit); // -459.67

All three temperature scales stay in sync automatically. Only _celsius stores the actual data, and the other two are derived through accessors.

Validation with Constructor Functions and Classes

Getters and setters work naturally in classes, making them the standard way to add validation:

class User {
#name;
#email;

constructor(name, email) {
// These assignments go through the setters
this.name = name;
this.email = email;
}

get name() {
return this.#name;
}

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();
}

get email() {
return this.#email;
}

set email(value) {
if (typeof value !== "string" || !value.includes("@")) {
throw new TypeError("Invalid email address");
}
this.#email = value.toLowerCase().trim();
}
}

const user = new User("Alice", "Alice@Example.com");
console.log(user.name); // "Alice"
console.log(user.email); // "alice@example.com" (normalized)

try {
user.email = "not-an-email";
} catch (e) {
console.log(e.message); // "Invalid email address"
}

Notice how the constructor calls this.name = name and this.email = email. These assignments trigger the setters, meaning validation runs even during object construction. This is a crucial detail: if you wrote this.#name = name directly, you would bypass the setter and skip validation.

Smart Getters: Lazy Evaluation and Caching

Sometimes computing a property value is expensive. You do not want to compute it upfront if it might never be accessed, and you do not want to recompute it every time it is accessed if the underlying data has not changed. This is where lazy evaluation and caching come in.

The Problem: Expensive Computation on Every Access

const report = {
data: [/* imagine thousands of entries */],

get summary() {
console.log("Computing summary..."); // Shows this runs every time
// Expensive calculation
return this.data.reduce((acc, item) => {
// complex aggregation logic
return acc + item;
}, 0);
}
};

// Each access re-runs the entire computation
report.summary; // "Computing summary..."
report.summary; // "Computing summary..." (computed again!)
report.summary; // "Computing summary..." (and again!)

Solution 1: Manual Caching with a Flag

const report = {
data: [10, 20, 30, 40, 50],
_summaryCache: null,
_cacheValid: false,

get summary() {
if (!this._cacheValid) {
console.log("Computing summary...");
this._summaryCache = this.data.reduce((acc, item) => acc + item, 0);
this._cacheValid = true;
}
return this._summaryCache;
},

addData(value) {
this.data.push(value);
this._cacheValid = false; // Invalidate cache
}
};

console.log(report.summary); // "Computing summary..." → 150
console.log(report.summary); // 150 (no recomputation)

report.addData(100);
console.log(report.summary); // "Computing summary..." → 250
console.log(report.summary); // 250 (cached again)

Solution 2: Lazy Property with Self-Replacing Getter

This is an elegant pattern where a getter computes the value once, then replaces itself with a regular data property. The next access reads the data property directly, with zero overhead.

const config = {
get platformInfo() {
console.log("Detecting platform...");

const info = {
userAgent: navigator.userAgent,
language: navigator.language,
platform: navigator.platform,
cores: navigator.hardwareConcurrency,
timestamp: Date.now()
};

// Replace the getter with a plain data property
Object.defineProperty(this, "platformInfo", {
value: info,
writable: false,
enumerable: true,
configurable: false
});

return info;
}
};

// First access triggers computation
console.log(config.platformInfo); // "Detecting platform..." → { userAgent: ..., ... }

// Second access reads the cached data property directly
console.log(config.platformInfo); // { userAgent: ..., ... } (no "Detecting platform..." log)

After the first access, platformInfo is no longer an accessor property. It has been silently replaced by a data property holding the computed result.

tip

This pattern is sometimes called a "lazy getter" or "memoized getter". It is useful for values that are expensive to compute but never change, like environment detection, heavy parsing, or one-time configuration.

Solution 3: Lazy Getter in Classes

The same pattern works in classes:

class ExpensiveReport {
#data;

constructor(data) {
this.#data = data;
}

get analysis() {
console.log("Running expensive analysis...");

const result = this.#data.reduce((acc, val) => {
// Simulate expensive work
return acc + val * Math.sqrt(val);
}, 0);

// Replace getter with a frozen value
Object.defineProperty(this, "analysis", {
value: result,
writable: false,
enumerable: true,
configurable: false
});

return result;
}
}

const report = new ExpensiveReport([1, 4, 9, 16, 25]);

console.log(report.analysis); // "Running expensive analysis..." → 195
console.log(report.analysis); // 195 (instant, no recomputation)

Comparing the Caching Approaches

ApproachBest ForCache InvalidationComplexity
Manual cache flagValues that change over timeSupportedMedium
Self-replacing getterValues computed once, never changeNot supportedLow
External memoizationPure functions with varying inputsVia cache clearingMedium

Compatibility: Using Getters and Setters as a Bridge

One of the most powerful uses of accessor properties is maintaining backward compatibility when an API needs to evolve. You can change how data is stored internally without breaking any code that depends on the old property interface.

Scenario: Migrating from a Property to a Getter/Setter

Imagine you originally had a simple User constructor with a plain age property:

// Original API (version 1)
function User(name, age) {
this.name = name;
this.age = age;
}

const user = new User("Alice", 25);
console.log(user.age); // 25
user.age = 30;

Now you want to store the birthday instead of the age, so the age is always accurate. But you have hundreds of places in your codebase that read and write user.age. You cannot break them.

The solution: replace the data property with an accessor property:

// Evolved API (version 2)
function User(name, birthday) {
this.name = name;
this.birthday = new Date(birthday);
}

Object.defineProperty(User.prototype, "age", {
get() {
const today = new Date();
let age = today.getFullYear() - this.birthday.getFullYear();
const monthDiff = today.getMonth() - this.birthday.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < this.birthday.getDate())) {
age--;
}
return age;
},

set(value) {
// Backward compatibility: setting age adjusts birthday
const today = new Date();
this.birthday = new Date(
today.getFullYear() - value,
today.getMonth(),
today.getDate()
);
},

enumerable: true,
configurable: true
});

const user = new User("Alice", "1995-06-15");

// Old code still works without any changes
console.log(user.age); // Computed dynamically based on birthday
user.age = 30; // Sets birthday to ~30 years ago
console.log(user.birthday); // Date object approximately 30 years in the past

Every line of existing code that reads or writes user.age continues to work. The internal storage changed from a plain number to a computed derivation from birthday, but the external interface stayed identical.

Real-World Example: Deprecation Warnings

You can use getters to warn developers about deprecated properties while keeping them functional:

function createAPIClient(config) {
const client = {
_baseUrl: config.baseUrl || config.url || "https://api.example.com",
_timeout: config.timeout || 5000
};

// Current property
Object.defineProperty(client, "baseUrl", {
get() {
return this._baseUrl;
},
set(value) {
this._baseUrl = value;
},
enumerable: true,
configurable: true
});

// Deprecated property that still works but warns
Object.defineProperty(client, "url", {
get() {
console.warn(
"DEPRECATED: 'url' is deprecated. Use 'baseUrl' instead."
);
return this._baseUrl;
},
set(value) {
console.warn(
"DEPRECATED: 'url' is deprecated. Use 'baseUrl' instead."
);
this._baseUrl = value;
},
enumerable: false, // Hide from enumeration
configurable: true
});

return client;
}

const client = createAPIClient({ baseUrl: "https://myapi.com" });

// New code uses the current API
console.log(client.baseUrl); // "https://myapi.com" (no warning)

// Old code still works but gets a warning
console.log(client.url);
// DEPRECATED: 'url' is deprecated. Use 'baseUrl' instead.
// "https://myapi.com"

Building a Proxy-Like Validation Layer

You can wrap an entire object with accessor properties that validate every field:

function createValidatedObject(schema, initialData = {}) {
const storage = {};
const obj = {};

for (const [key, rules] of Object.entries(schema)) {
storage[key] = initialData[key] !== undefined ? initialData[key] : rules.default;

Object.defineProperty(obj, key, {
get() {
return storage[key];
},
set(value) {
if (rules.type && typeof value !== rules.type) {
throw new TypeError(
`"${key}" must be of type ${rules.type}, got ${typeof value}`
);
}
if (rules.min !== undefined && value < rules.min) {
throw new RangeError(
`"${key}" must be at least ${rules.min}, got ${value}`
);
}
if (rules.max !== undefined && value > rules.max) {
throw new RangeError(
`"${key}" must be at most ${rules.max}, got ${value}`
);
}
if (rules.pattern && !rules.pattern.test(value)) {
throw new Error(
`"${key}" does not match the required pattern`
);
}
storage[key] = value;
},
enumerable: true,
configurable: false
});
}

return obj;
}

// Define a schema
const product = createValidatedObject({
name: { type: "string", default: "" },
price: { type: "number", min: 0, max: 99999, default: 0 },
sku: { type: "string", pattern: /^[A-Z]{3}-\d{4}$/, default: "" }
}, {
name: "Widget",
price: 29.99,
sku: "WDG-0001"
});

console.log(product.name); // "Widget"
console.log(product.price); // 29.99

product.price = 49.99;
console.log(product.price); // 49.99

try {
product.price = -10;
} catch (e) {
console.log(e.message); // "price" must be at least 0, got -10
}

try {
product.sku = "invalid";
} catch (e) {
console.log(e.message); // "sku" does not match the required pattern
}

Common Pitfalls and Best Practices

Pitfall 1: Infinite Recursion in Getters/Setters

A getter or setter that reads or writes to the same property name causes infinite recursion:

// WRONG: infinite recursion
const user = {
get name() {
return this.name; // Calls the getter again!
},
set name(value) {
this.name = value; // Calls the setter again!
}
};

try {
user.name = "Alice"; // RangeError: Maximum call stack size exceeded
} catch (e) {
console.log(e.message);
}

The fix: store the actual value in a different property, typically prefixed with an underscore or using a private field:

// CORRECT: separate storage property
const user = {
_name: "",

get name() {
return this._name;
},
set name(value) {
this._name = value;
}
};

user.name = "Alice";
console.log(user.name); // "Alice"

Pitfall 2: Getters with Side Effects

Getters should be pure and predictable. Avoid getters that modify state, because code reading a property does not expect side effects:

// BAD: getter modifies state
const counter = {
_count: 0,

get value() {
this._count++; // Side effect! Reading changes state
return this._count;
}
};

console.log(counter.value); // 1
console.log(counter.value); // 2 (different result for the same read!)
// GOOD: use a method if there are side effects
const counter = {
_count: 0,

get value() {
return this._count;
},

increment() {
this._count++;
return this._count;
}
};

console.log(counter.value); // 0
console.log(counter.increment()); // 1
console.log(counter.value); // 1 (consistent)

Pitfall 3: Forgetting That Accessor Properties Have No value

You cannot check an accessor property's stored value through its descriptor:

const obj = {
_x: 10,
get x() { return this._x; }
};

const desc = Object.getOwnPropertyDescriptor(obj, "x");
console.log(desc.value); // undefined (accessors don't have 'value')
console.log(desc.get); // [Function: get x]
warning

When cloning objects with Object.assign() or the spread operator, accessor properties are invoked during the copy. The result is a data property with the returned value, not a copy of the getter/setter:

const original = {
_val: 42,
get value() { return this._val; },
set value(v) { this._val = v; }
};

const clone = { ...original };
console.log(Object.getOwnPropertyDescriptor(clone, "value"));
// { value: 42, writable: true, enumerable: true, configurable: true }
// It's now a plain data property! The getter/setter is gone.

To preserve accessors during cloning, use Object.defineProperties with Object.getOwnPropertyDescriptors:

const clone = Object.defineProperties(
{},
Object.getOwnPropertyDescriptors(original)
);

console.log(Object.getOwnPropertyDescriptor(clone, "value"));
// { get: [Function], set: [Function], enumerable: true, configurable: true }

Summary

Accessor properties are one of JavaScript's most underutilized features. They let you put logic behind what looks like simple property access, without changing how consumers interact with your objects.

ConceptKey Point
get propertyName()Function that runs when the property is read
set propertyName(value)Function that runs when the property is assigned
Accessor descriptorHas get, set, enumerable, configurable (no value or writable)
Data descriptorHas value, writable, enumerable, configurable (no get or set)
Cannot mixA property is either data or accessor, never both
ValidationSetters can enforce type, range, and format rules
Computed propertiesGetters can derive values from other properties dynamically
Lazy evaluationGetters can compute on first access, then cache the result
API compatibilityReplace a data property with a getter/setter without changing the external interface
Cloning trap{ ...obj } and Object.assign invoke getters and lose the accessor definition

Key rules to remember:

  • Always use a separate storage property (like _name or #name) to avoid infinite recursion
  • Keep getters side-effect free and predictable
  • Use Object.getOwnPropertyDescriptors() when you need to clone objects with accessors intact
  • Prefer accessor properties over manual getter/setter methods (like getName()/setName()) for a cleaner, more natural API