JavaScript Getters and Setters: Accessor Properties

JavaScript properties come in two flavors: data properties and accessor properties. Data properties are the standard key-value pairs you work with every day. Accessor properties, on the other hand,...

Key Insights

  • Getters and setters allow you to run custom code when accessing or modifying object properties, enabling validation, computed values, and encapsulation without changing your API
  • Accessor properties look like regular properties to consumers but execute functions behind the scenes—no parentheses needed when accessing them
  • Use getters for computed properties and read-only values; use setters for validation and side effects, but avoid them for expensive operations that users won’t expect

Introduction to Accessor Properties

JavaScript properties come in two flavors: data properties and accessor properties. Data properties are the standard key-value pairs you work with every day. Accessor properties, on the other hand, are functions disguised as properties—they execute code when you read or write a value.

Here’s the fundamental difference:

// Data property - direct value storage
const user = {
  name: 'Alice'
};
console.log(user.name); // 'Alice'

// Accessor property - function execution
const user2 = {
  firstName: 'Alice',
  lastName: 'Johnson',
  get name() {
    return `${this.firstName} ${this.lastName}`;
  }
};
console.log(user2.name); // 'Alice Johnson' - function runs automatically

Getters and setters exist to solve specific problems: validating input, computing values on-the-fly, maintaining backwards compatibility, and implementing encapsulation. They let you control what happens when properties are accessed or modified without forcing consumers to call methods explicitly.

Basic Getter and Setter Syntax

The get and set keywords define accessor properties. You can use them in object literals or ES6 classes.

Object Literal Syntax:

const rectangle = {
  width: 10,
  height: 5,
  
  get area() {
    return this.width * this.height;
  },
  
  set area(value) {
    // Set width, maintaining aspect ratio
    const ratio = this.width / this.height;
    this.height = Math.sqrt(value / ratio);
    this.width = value / this.height;
  }
};

console.log(rectangle.area); // 50 - getter runs
rectangle.area = 100;         // setter runs
console.log(rectangle.width); // ~14.14
console.log(rectangle.height); // ~7.07

ES6 Class Syntax:

class Circle {
  constructor(radius) {
    this.radius = radius;
  }
  
  get diameter() {
    return this.radius * 2;
  }
  
  set diameter(value) {
    this.radius = value / 2;
  }
  
  get area() {
    return Math.PI * this.radius ** 2;
  }
}

const circle = new Circle(5);
console.log(circle.diameter); // 10
circle.diameter = 20;
console.log(circle.radius);   // 10

Notice you access these properties without parentheses. circle.diameter, not circle.diameter(). This is the key advantage—your API looks like simple property access, but you control the behavior.

Practical Use Cases

Computed Properties:

The most common use case is deriving values from other properties:

class Temperature {
  constructor(celsius) {
    this._celsius = celsius;
  }
  
  get celsius() {
    return this._celsius;
  }
  
  set celsius(value) {
    this._celsius = value;
  }
  
  get fahrenheit() {
    return this._celsius * 9/5 + 32;
  }
  
  set fahrenheit(value) {
    this._celsius = (value - 32) * 5/9;
  }
}

const temp = new Temperature(0);
console.log(temp.fahrenheit); // 32
temp.fahrenheit = 98.6;
console.log(temp.celsius);    // 37

Input Validation:

Setters are perfect for enforcing constraints:

class User {
  constructor(email) {
    this.email = email; // Uses setter
  }
  
  get email() {
    return this._email;
  }
  
  set email(value) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(value)) {
      throw new Error('Invalid email format');
    }
    this._email = value.toLowerCase();
  }
}

const user = new User('Alice@Example.com');
console.log(user.email); // 'alice@example.com'
// user.email = 'invalid'; // Throws error

Encapsulation:

Combine multiple properties into a convenient interface:

class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }
  
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
  
  set fullName(value) {
    const parts = value.split(' ');
    this.firstName = parts[0];
    this.lastName = parts.slice(1).join(' ');
  }
}

const person = new Person('John', 'Doe');
console.log(person.fullName);  // 'John Doe'
person.fullName = 'Jane Smith';
console.log(person.firstName); // 'Jane'
console.log(person.lastName);  // 'Smith'

Advanced Patterns

Private Properties with Public Getters:

Use private class fields to truly hide internal state:

class BankAccount {
  #balance = 0;
  
  constructor(initialBalance) {
    this.#balance = initialBalance;
  }
  
  get balance() {
    return this.#balance;
  }
  
  deposit(amount) {
    if (amount > 0) {
      this.#balance += amount;
    }
  }
  
  withdraw(amount) {
    if (amount > 0 && amount <= this.#balance) {
      this.#balance -= amount;
      return true;
    }
    return false;
  }
}

const account = new BankAccount(1000);
console.log(account.balance); // 1000
// account.#balance = 5000;   // SyntaxError: private field
account.deposit(500);
console.log(account.balance); // 1500

Read-Only Properties:

Define a getter without a setter to create immutable properties:

class Configuration {
  constructor(apiKey) {
    this._apiKey = apiKey;
    this._createdAt = new Date();
  }
  
  get apiKey() {
    return this._apiKey;
  }
  
  get createdAt() {
    return this._createdAt;
  }
}

const config = new Configuration('secret-key-123');
console.log(config.apiKey);    // 'secret-key-123'
config.apiKey = 'new-key';     // Silently fails in non-strict mode
console.log(config.apiKey);    // Still 'secret-key-123'

Lazy Initialization:

Defer expensive computations until actually needed:

class DataProcessor {
  #cachedResult = null;
  
  constructor(data) {
    this.data = data;
  }
  
  get processedData() {
    if (this.#cachedResult === null) {
      console.log('Computing expensive result...');
      this.#cachedResult = this.data
        .map(x => x * 2)
        .filter(x => x > 10)
        .reduce((a, b) => a + b, 0);
    }
    return this.#cachedResult;
  }
}

const processor = new DataProcessor([1, 5, 10, 15, 20]);
// No computation yet
console.log(processor.processedData); // Logs "Computing...", returns 70
console.log(processor.processedData); // Returns 70 immediately (cached)

Object.defineProperty() Alternative

You can add getters and setters to existing objects dynamically:

const product = {
  name: 'Widget',
  price: 100
};

Object.defineProperty(product, 'formattedPrice', {
  get() {
    return `$${this.price.toFixed(2)}`;
  },
  enumerable: true,
  configurable: true
});

console.log(product.formattedPrice); // '$100.00'
product.price = 150.5;
console.log(product.formattedPrice); // '$150.50'

Define multiple properties at once:

const stats = { wins: 0, losses: 0 };

Object.defineProperties(stats, {
  total: {
    get() { return this.wins + this.losses; }
  },
  winRate: {
    get() { 
      return this.total === 0 ? 0 : this.wins / this.total;
    }
  }
});

stats.wins = 7;
stats.losses = 3;
console.log(stats.total);    // 10
console.log(stats.winRate);  // 0.7

The enumerable and configurable options control whether properties show up in loops and whether they can be deleted or redefined.

Common Pitfalls and Best Practices

Avoid Infinite Loops:

Never reference the same property name inside its setter:

// WRONG - infinite loop
class Bad {
  set value(val) {
    this.value = val; // Calls setter again!
  }
}

// RIGHT - use a different internal property
class Good {
  set value(val) {
    this._value = val;
  }
  
  get value() {
    return this._value;
  }
}

Performance Considerations:

Getters run every time you access the property. Don’t do expensive work:

// BAD - expensive operation in getter
class SlowList {
  constructor(items) {
    this.items = items;
  }
  
  get sorted() {
    return this.items.slice().sort(); // Sorts every access!
  }
}

// BETTER - cache or use a method
class FastList {
  constructor(items) {
    this.items = items;
  }
  
  getSorted() {
    return this.items.slice().sort(); // User knows it's a computation
  }
}

When NOT to Use Getters/Setters:

  • Don’t use them for asynchronous operations (getters can’t be async)
  • Avoid them when the operation has significant side effects that aren’t obvious
  • Skip them for simple property access with no logic—they add complexity without benefit

Debugging Tips:

Getters and setters can make debugging harder because they hide function calls. Use Object.getOwnPropertyDescriptor() to inspect them:

const obj = {
  get value() { return 42; }
};

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

Conclusion

Getters and setters are powerful tools for creating clean, maintainable APIs. Use getters for computed properties, derived values, and read-only access. Use setters for validation, normalization, and maintaining invariants. They shine when you need to add logic to property access without changing how consumers interact with your objects.

The key is restraint. Not every property needs to be an accessor. Use them when they solve a specific problem: validation, computation, encapsulation, or backwards compatibility. When you do use them, keep the logic simple and predictable. Your future self—and your teammates—will thank you.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.