Emmanuel Akinfulubi

Lesson 09 of 1511 min read

Getters and Setters

A property that is secretly a function. Used well, they let you change how something is stored without breaking a single line of code that reads it.

By the end: Expose computed values and validate assignment.

A getter is a method you read without brackets. That sounds like a difference in looks only. It is not, because it means you can change how a value is produced without changing a single line of the code that reads it.

Read like a property, computed like a method

class Bus {
  constructor(plate, seats) {
    this.plate = plate;
    this.seats = seats;
  }

  // Note: no brackets at the call site.
  get size() {
    if (this.seats > 30) return 'full coach';
    if (this.seats > 14) return 'coaster';
    return 'minibus';
  }

  get label() {
    return `${this.plate} (${this.size})`;
  }
}

const b = new Bus('LAG-441-XA', 14);
console.log(b.size);
console.log(b.label);

b.seats = 33;
console.log('after a refit:', b.label);

Output


      

The refactor that getters make free

Start with a stored property. Realise later it should be computed. With a getter, nothing that reads it has to change.

Version one: total is stored, and drifts out of date

class Cart {
  constructor() {
    this.items = [];
    this.total = 0;
  }
  add(price) {
    this.items.push(price);
    this.total += price;
  }
}

const cart = new Cart();
cart.add(12);
cart.add(8);
console.log('total:', cart.total);

// Someone edits the array directly, and the total is now a lie.
cart.items.push(100);
console.log('items:', cart.items, '| total says:', cart.total);

Output


      

Version two: total is derived, and cannot drift

class Cart {
  #items = [];

  add(price) {
    this.#items.push(price);
    return this;
  }

  get items() {
    return [...this.#items];      // a copy, so callers cannot edit the real one
  }

  get total() {
    return this.#items.reduce((sum, p) => sum + p, 0);
  }

  get count() {
    return this.#items.length;
  }
}

const cart = new Cart();
cart.add(12).add(8);
console.log('total:', cart.total, '| count:', cart.count);

// The same attempt now changes nothing, because items hands back a copy.
cart.items.push(100);
console.log('items:', cart.items, '| total:', cart.total);

Output


      

Setters, and the one job they are good at

A setter runs when someone assigns to the property. Its honest use is to check the value and tidy it up on the way in.

A setter that refuses nonsense

class Bus {
  #seats = 14;

  constructor(plate) { this.plate = plate; }

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

  set seats(value) {
    const n = Number(value);
    if (!Number.isInteger(n)) throw new TypeError('seats must be a whole number.');
    if (n < 1 || n > 59) throw new RangeError(`seats must be 1 to 59, got ${n}.`);
    this.#seats = n;
  }
}

const b = new Bus('LAG-441-XA');
b.seats = 33;
console.log(b.seats);

b.seats = '44';                   // normalised on the way in
console.log(b.seats, typeof b.seats);

try { b.seats = 0; } catch (err) { console.log('refused:', err.message); }
try { b.seats = 2.5; } catch (err) { console.log('refused:', err.message); }

Output


      

The three ways setters go wrong

Trap 1: the infinite loop

class Broken {
  set label(value) {
    // This assignment calls the setter again. And again.
    this.label = value.trim();
  }
  get label() { return this._nothing; }
}

try {
  new Broken().label = '  x  ';
} catch (err) {
  console.log(err.name + ':', err.message);
}

// The fix is to store somewhere else: a private field, or a differently named one.
class Fixed {
  #label = '';
  set label(value) { this.#label = String(value).trim(); }
  get label() { return this.#label; }
}
const f = new Fixed();
f.label = '   Lagos - Abuja   ';
console.log('fixed:', JSON.stringify(f.label));

Output


      

Trap 2: a getter with no setter refuses the write

class ReadOnly {
  #value = 42;
  get value() { return this.#value; }
}

const r = new ReadOnly();

// In strict mode, which is every module and every class body, this throws.
// In an old sloppy-mode script it was ignored in silence, and the caller
// walked away believing the assignment had worked.
try {
  r.value = 99;
} catch (err) {
  console.log(err.name + ':', err.message);
}
console.log('value is still', r.value);

// Either way the message is about mechanics, not intent. Say what you mean.
class Loud {
  #value = 42;
  get value() { return this.#value; }
  set value(_) {
    throw new TypeError('value is derived and cannot be assigned. Use recalculate().');
  }
}

try { new Loud().value = 99; } catch (err) { console.log('refused:', err.message); }

Output


      

Trap 3: an expensive getter called in a loop

class Slow {
  #rows = Array.from({ length: 200 }, (_, i) => i);

  get worst() {
    // Pretend this is a real calculation.
    return this.#rows.reduce((a, b) => a + b, 0);
  }
}

const s = new Slow();
let touched = 0;
for (let i = 0; i < 5; i++) {
  touched += s.worst;             // recomputed every single time
}
console.log('sum of five reads:', touched);

// Cache it when the cost is real and the inputs do not change.
class Cached {
  #rows = Array.from({ length: 200 }, (_, i) => i);
  #sum = null;

  get worst() {
    if (this.#sum === null) this.#sum = this.#rows.reduce((a, b) => a + b, 0);
    return this.#sum;
  }

  add(n) { this.#rows.push(n); this.#sum = null; return this; }
}

const c = new Cached();
console.log('cached read:', c.worst, c.worst, c.worst);
c.add(1000);
console.log('after add, recomputed:', c.worst);

Output


      

Getters on object literals, and defining them by hand

Same feature, outside a class

const hold = {
  widthM: 2,
  depthM: 5,

  get areaM2() { return this.widthM * this.depthM; },
  set areaM2(_) { throw new TypeError('Set widthM and depthM instead.'); },
};

console.log('luggage floor area:', hold.areaM2);
hold.widthM = 3;
console.log('after widening:', hold.areaM2);

// And the fully explicit form, which is what a getter compiles down to.
const seat = {};
Object.defineProperty(seat, 'label', {
  get() { return 'D12'; },
  enumerable: true,
});
console.log(seat.label);
console.log('descriptor:', Object.getOwnPropertyDescriptor(seat, 'label'));

Output


      

toJSON, so the shape you send is the shape you meant

class Ticket {
  #serial = 'A-1';
  constructor(row, seat) { this.row = row; this.seat = seat; }

  get label() { return `${this.row}${this.seat}`; }

  toJSON() {
    return { serial: this.#serial, label: this.label };
  }
}

const t = new Ticket('D', 12);
console.log('without toJSON the label would vanish. With it:');
console.log(JSON.stringify(t));

Output


      

Your turn: temperature, both ways

Write a Reading class that stores celsius in a private field. Give it a celsius getter and setter, and a fahrenheit getter and setter that convert. Setting either should be visible through the other. Reject anything below absolute zero, which is -273.15 C.

Editable. Fill it in, then run it.

Output


      

Recap

  • A getter is read without brackets, which lets you replace a stored value with a computed one without touching any caller.
  • Use a getter for facts about the object, a method for actions and for anything slow.
  • A getter that hands back internal state should hand back a copy.
  • Never assign to the same property name inside its own setter; store in a private field instead.
  • Class getters do not appear in JSON.stringify; add toJSON() when the serialised shape matters.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.