Complete JavaScript Course Module 7: Intermediate: Objects and JSON

Object Constructors, Getters, Setters and Object.freeze

  • Beginner
  • Lesson 7.2
  • 6 min read
  • 28 of 76 in the course

In short

JavaScript object constructors, getters and setters give you more control over objects. Learn constructor functions with new, get and set, Object.defineProperty, and Object.freeze.

JavaScript object constructors, getters and setters give you more control over objects. Learn constructor functions with new, get and set, Object.defineProperty, and Object.freeze.

Constructor functions and the new keyword

When you need many similar objects, like products in a shop, writing each object literal by hand gets repetitive. Before classes existed, JavaScript developers used constructor functions: normal functions that are meant to be called with the new keyword to build objects. By convention their names start with a capital letter.

function Product(name, price) {
  this.name = name;
  this.price = price;
  this.inStock = true;
}

const mug = new Product("Coffee mug", 12);
const lamp = new Product("Desk lamp", 35);

console.log(mug.name, mug.price);    // Output: Coffee mug 12
console.log(lamp);                   // Output: Product { name: 'Desk lamp', price: 35, inStock: true }
console.log(mug instanceof Product); // Output: true

When you write new Product(...), JavaScript does four things for you:

  1. Creates a brand-new empty object.
  2. Links that object to Product.prototype, so it can share methods.
  3. Runs the function with this pointing to the new object, which fills in the properties.
  4. Returns the new object automatically.

Methods were added to the prototype, so all products share one copy instead of each object getting its own:

Product.prototype.label = function () {
  return `${this.name} - $${this.price.toFixed(2)}`;
};

console.log(mug.label());    // Output: Coffee mug - $12.00
console.log(lamp.label());   // Output: Desk lamp - $35.00

Common mistake: Forgetting new. Calling Product("Pen", 2) without it runs the function as a plain call. In strict mode, this is undefined and you get a TypeError; in old sloppy mode, it silently creates global variables called name and price. Classes refuse to be called without new, which prevents this bug.

Why classes replaced constructor functions

The class syntax, which you will study in the Classes lesson, does exactly the same job with clearer code. Under the hood, a class still creates a constructor function and a prototype. Here is the same product as a class:

class ProductItem {
  constructor(name, price) {
    this.name = name;
    this.price = price;
    this.inStock = true;
  }

  label() {
    return `${this.name} - $${this.price.toFixed(2)}`;
  }
}

const book = new ProductItem("Notebook", 4.5);
console.log(book.label());   // Output: Notebook - $4.50
// ProductItem("Pen", 2);    // TypeError: Class constructor ProductItem cannot be invoked without 'new'

Everything is in one block, methods go on the prototype automatically, forgetting new throws a clear error, and the body is always in strict mode. Write classes in new code. You still need to recognise constructor functions, because you will see them in older code and libraries.

Getters and setters in object literals

A getter is a property that runs a function when you read it. A setter runs a function when you assign to it. From the outside they look like ordinary properties: no brackets needed. You write them with the get and set keywords, even in a plain object literal:

const user = {
  firstName: "Amara",
  lastName: "Okafor",

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

  set fullName(value) {
    const [first, last] = value.split(" ");
    this.firstName = first;
    this.lastName = last;
  },
};

console.log(user.fullName);    // Output: Amara Okafor   (no brackets!)

user.fullName = "Leo Martin";  // runs the setter
console.log(user.firstName);   // Output: Leo
console.log(user.lastName);    // Output: Martin

The getter builds fullName from the other two properties every time, so it is always up to date. The setter does the opposite: it splits the new value and stores the parts.

Setters are also great for validation, which means checking a value before you accept it:

const thermostat = {
  _celsius: 20,   // the underscore says "please don't touch this directly"

  get celsius() {
    return this._celsius;
  },
  set celsius(value) {
    if (typeof value !== "number" || value < 5 || value > 30) {
      console.log(`Ignored ${value}: choose 5 to 30 degrees`);
      return;
    }
    this._celsius = value;
  },
  get fahrenheit() {
    return this._celsius * 9 / 5 + 32;
  },
};

thermostat.celsius = 25;
console.log(thermostat.fahrenheit);  // Output: 77
thermostat.celsius = 90;             // Output: Ignored 90: choose 5 to 30 degrees
console.log(thermostat.celsius);     // Output: 25

fahrenheit has only a getter, so it is a read-only calculated value.

Object.defineProperty basics

Every property has hidden settings called property descriptors. Object.defineProperty(object, name, descriptor) lets you set them yourself:

SettingMeaningDefault with defineProperty
valueThe property's valueundefined
writableCan the value be changed?false
enumerableDoes it show up in for...in, Object.keys and JSON.stringify?false
configurableCan the property be deleted or its settings changed?false
"use strict";

const order = { total: 59.9 };

Object.defineProperty(order, "id", {
  value: "ORD-1042",
  writable: false,     // read-only
  enumerable: false,   // hidden from loops and JSON
});

console.log(order.id);              // Output: ORD-1042
console.log(Object.keys(order));    // Output: [ 'total' ]
console.log(JSON.stringify(order)); // Output: {"total":59.9}

try {
  order.id = "HACKED";
} catch (error) {
  console.log(error.name);          // Output: TypeError
}

Normal properties you create with = or in a literal have all three settings set to true. With defineProperty, anything you leave out becomes false, so be explicit. You will not use it every day, but it explains how built-in objects keep some properties read-only and hidden.

Object.freeze, Object.seal and Object.preventExtensions

Sometimes you want to protect a whole object, like a settings object that should never change while the app runs. JavaScript gives you three levels of protection:

MethodAdd new properties?Delete properties?Change existing values?Check with
Object.preventExtensions(obj)NoYesYesObject.isExtensible(obj)
Object.seal(obj)NoNoYesObject.isSealed(obj)
Object.freeze(obj)NoNoNoObject.isFrozen(obj)
const config = Object.freeze({
  apiUrl: "https://api.example.com",
  theme: { color: "blue" },
});

config.apiUrl = "https://evil.example.com";   // ignored (TypeError in strict mode)
config.debug = true;                          // ignored
console.log(config.apiUrl);                   // Output: https://api.example.com
console.log(Object.isFrozen(config));         // Output: true

config.theme.color = "red";                   // this works!
console.log(config.theme.color);              // Output: red

That last line shows the important catch: Object.freeze is shallow. It freezes only the top level. The nested theme object is a separate object, and it is not frozen. To freeze everything, you can freeze each nested object too, using recursion:

function deepFreeze(obj) {
  for (const value of Object.values(obj)) {
    if (typeof value === "object" && value !== null) {
      deepFreeze(value);           // freeze nested objects and arrays first
    }
  }
  return Object.freeze(obj);
}

const settings = deepFreeze({ theme: { color: "blue" }, sizes: [12, 14] });
settings.theme.color = "red";      // ignored now (TypeError in strict mode)
console.log(settings.theme.color); // Output: blue
settings.sizes.push(16);           // TypeError: Cannot add property 2, object is not extensible

Note: const and Object.freeze protect different things. const stops the variable from pointing to a different object. Object.freeze stops the object itself from changing. For a truly fixed settings object, use both.

Practice exercise

Create a bankAccount object with a _balance of 100, a getter balance, and a setter balance that refuses negative numbers with a console message. Add a getter summary that returns a string like Balance: $100.00. Then create const rates = Object.freeze({ savings: 0.03, nested: { bonus: 0.01 } }), try to change both savings and nested.bonus, and log which one changed.

Hint: use toFixed(2) in the summary getter. Only the nested value changes, because Object.freeze is shallow.

Key takeaways

  • Constructor functions build objects with new; methods go on the prototype. Modern code uses classes instead.
  • Getters (get) run when you read a property; setters (set) run when you assign one, which is perfect for validation.
  • Object.defineProperty controls a property's writable, enumerable and configurable settings.
  • preventExtensions, seal and freeze give three levels of protection, and freeze is shallow.