Skip to content

How to Use Property Getters and Setters in JavaScript

How JavaScript getters and setters work with accessor properties, including syntax, validation, computed values, lazy evaluation, and backward-compatible API design patterns.

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"]

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:

Field Description
value The stored value
writable Whether the value can be changed
enumerable Whether it appears in enumeration
configurable Whether 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:

Field Description
get Function called when reading the property
set Function called when writing to the property
enumerable Whether it appears in enumeration
configurable Whether 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
}

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)

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 {

  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.

Solution 3: Lazy Getter in Classes

The same pattern works in classes:

class ExpensiveReport {

  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

Approach Best For Cache Invalidation Complexity
Manual cache flag Values that change over time Supported Medium
Self-replacing getter Values computed once, never change Not supported Low
External memoization Pure functions with varying inputs Via cache clearing Medium

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]

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.

Concept Key Point
get propertyName() Function that runs when the property is read
set propertyName(value) Function that runs when the property is assigned
Accessor descriptor Has get, set, enumerable, configurable (no value or writable)
Data descriptor Has value, writable, enumerable, configurable (no get or set)
Cannot mix A property is either data or accessor, never both
Validation Setters can enforce type, range, and format rules
Computed properties Getters can derive values from other properties dynamically
Lazy evaluation Getters can compute on first access, then cache the result
API compatibility Replace 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
Navigation