Mastering JavaScript Objects: Creation, Descriptors, and Immutability

17 min read, Sun, 01 Jun 2025

JavaScript Types Image from pixabay.com

JavaScript objects are dynamic collections of key-value pairs, where keys are strings (or Symbols) and values can be any data type, including other objects.

Think of an object’s properties as the specific values associated with it. Let’s look at an example:

const person = {
    name: "John",
    age: 30,

    getName: function () {
        console.log("Person Name: " + this.name);
    },
};

console.log(person.name); // Output John
console.log(person["age"]); // Output 30
person.getName(); // Output: Person Name: John

In this snippet, we’ve crafted an object literal containing name and age as keys. We then assign this literal to the person variable. Notice how we can effortlessly access these properties using either dot notation (person.name) or array-like bracket notation (person["age"]), as demonstrated by the console.log statements.

The getName function, in this case, is a method of the person object. When you call it using person.getName(), the this keyword inside the getName method conveniently refers to the person object itself.

However, things get interesting if you try to call getName without its person context. In that scenario, the this keyword inside the function body will surprisingly refer to the global scope. Let’s see what happens when we call getName without its person reference:

const getNameNoRef = person.getName;
getNameNoRef(); // calls getName but this refers to global object.
//Output: Person Name: undefined.

Finally, it’s worth noting that Object itself is an intrinsic, built-in object in JavaScript, offering a suite of predefined properties and methods for you to leverage.


Diving into Property Descriptors

Every property on a JavaScript object isn’t just a simple key-value pair; it comes with a set of hidden attributes, collectively known as its property descriptor. These descriptors define the characteristics and behavior of the property, giving you fine-grained control over how that property can be accessed, modified, listed, or even deleted.

Think of them as meta-information about the property itself. There are two main types of property descriptors:

  1. Data Descriptors: These describe properties that simply hold a value.
  2. Accessor Descriptors: These describe properties that are defined by a getter-setter pair. Both types share some common attributes, and each has its own specific ones:

Common Attributes:

You cannot change any of its other attributes… with one very specific exception for writable:

  • If a data property is configurable: false and currently writable: true, you can still change its writable attribute from true to false. Think of this as a one-way trip to make it even more immutable. Once it’s writable: false (and still configurable: false), it’s truly locked down.

  • However, if a data property is configurable: false and already writable: false, you cannot change its writable attribute back to true. That path is permanently blocked.

Changing the property’s value:

  • If the property is a data property and its writable attribute is true (regardless of configurable), you can still change its value using assignment (=).
  • If the property is an accessor property, its value is controlled by the get and set functions. The configurable: false only prevents you from changing which get or set functions are used, or deleting them; it doesn’t stop the set function from doing its job if it’s there.

Data Descriptor Specific Attributes:

Accessor Descriptor Specific Attributes:

A property cannot be both a data descriptor and an accessor descriptor simultaneously. If you try to define both value (or writable) and get (or set) for the same property, it will throw an error.


Multiple Approaches to Object Creation

You’re already familiar with object literals, but let’s quickly recap its elegance and then dive into other versatile methods.

1. Object Literal (The Simplest & Most Common)

You’ve already seen this in action, and it’s by far the most direct and widely used way to create a single object.

Example:

const mySimpleObject = {
    firstName: "Alice",
    lastName: "Wonderland",
    age: 30,
    greet() {
        console.log(`Hello, my name is ${this.firstName}!`);
    },
};

console.log(mySimpleObject.firstName); // Output: Alice
mySimpleObject.greet(); // Output: Hello, my name is Alice!

2. Object.create() (The Prototypal Way)

This method allows you to create a new object and explicitly set its prototype (i.e., the object it will inherit properties and methods from).

Example 1:

const personPrototype = {
    greet() {
        console.log(`Hello, my name is ${this.name}.`);
    },
};

const john = Object.create(personPrototype);
john.name = "John"; // 'name' is an own property of john
john.age = 30;

const jane = Object.create(personPrototype);
jane.name = "Jane";

john.greet(); // Output: Hello, my name is John. (greet is inherited from personPrototype)
jane.greet(); // Output: Hello, my name is Jane.
console.log(Object.getPrototypeOf(john) === personPrototype); // Output: true

As we discussed earlier, Object.create() allows you to set the prototype of a new object. But its true power for precise object construction comes from its optional second argument.

Using the second argument with Object.create() gives you immense power to create objects with carefully controlled properties right from the start, combining prototypal inheritance with precise property definition. This is very useful when building sophisticated object structures or libraries where property behavior needs to be strictly managed.

Example 2:

const baseVehicle = {
    wheels: 4,
    start() {
        console.log("Engine starting...");
    },
};

// Creating a 'car' object inheriting from 'baseVehicle'
// and defining its own properties with specific descriptors
const car = Object.create(baseVehicle, {
    // Defining 'model' as a data property
    model: {
        value: "Sedan X",
        writable: true, // Can be changed
        enumerable: true, // Will show up in loops
        configurable: false, // Cannot redefine or delete this property
    },
    // Defining 'topSpeedKmH' as an accessor property
    topSpeedKmH: {
        get: function () {
            return this._speed || 180; // Default if _speed isn't set
        },
        set: function (speed) {
            if (speed > 300) {
                console.log("Speed limited to 300 km/h for safety!");
                this._speed = 300;
            } else {
                this._speed = speed;
            }
        },
        enumerable: true,
        configurable: true,
    },
});

console.log(car.wheels); // Output: 4 (inherited from baseVehicle)
car.start(); // Output: Engine starting... (inherited)

console.log(car.model); // Output: Sedan X
car.model = "Coupe Y"; // This works because writable is true
console.log(car.model); // Output: Coupe Y

console.log(car.topSpeedKmH); // Output: 180 (getter provides default)
car.topSpeedKmH = 220; // Setter is invoked
console.log(car.topSpeedKmH); // Output: 220

car.topSpeedKmH = 350; // Setter limits the speed
console.log(car.topSpeedKmH); // Output: 300 (limited by setter)

// Attempting to delete a non-configurable property
delete car.model; // This will return false (or throw TypeError in strict mode)
console.log(car.model); // Output: Coupe Y (property not deleted)

// Getting the descriptor to confirm:
console.log(Object.getOwnPropertyDescriptor(car, "model"));
/* Output:
{
  value: 'Coupe Y',
  writable: true,
  enumerable: true,
  configurable: false
}
*/

3. Object.fromEntries() (From Iterables to Objects)

This method is super handy for converting data structures that store key-value pairs as iterable entries (like Map objects or arrays of arrays) into a new object.

Example:

const dataArray = [
    ["name", "Charlie"],
    ["age", 25],
    ["city", "New York"],
];

const personObject = Object.fromEntries(dataArray);
console.log(personObject); // Output: { name: 'Charlie', age: 25, city: 'New York' }

// Also works great with Maps:
const dataMap = new Map([
    ["product", "Laptop"],
    ["price", 1200],
    ["inStock", true],
]);

const productObject = Object.fromEntries(dataMap);
console.log(productObject); // Output: { product: 'Laptop', price: 1200, inStock: true }

4 Object.assign() (Merging Objects)

Object.assign() is primarily used for copying properties from one or more source objects into a target object. It’s less about creating a completely new object from scratch and more about composing one from existing parts.

Example:

const defaults = {
    theme: "dark",
    fontSize: "medium",
};

const userSettings = {
    fontSize: "large", // Overwrites default
    notifications: true,
};

// Creating a new object by merging (empty object as target)
const finalSettings = Object.assign({}, defaults, userSettings);
console.log(finalSettings);
// Output: { theme: 'dark', fontSize: 'large', notifications: true }

// Modifying an existing object
const targetObject = { id: 1 };
Object.assign(targetObject, userSettings);
console.log(targetObject);
// Output: { id: 1, fontSize: 'large', notifications: true }

5. Object.groupBy() (Grouping Elements - A Newer Addition!)

Object.groupBy() is a relatively newer method (standardized in ES2024, so ensure your environment supports it) that provides a convenient way to group elements from an iterable based on a classifying function. The result is an object where keys are the group names and values are arrays of elements belonging to that group.

Example:

const products = [
    { name: "Laptop", category: "Electronics" },
    { name: "T-Shirt", category: "Apparel" },
    { name: "Mouse", category: "Electronics" },
    { name: "Jeans", category: "Apparel" },
];

const groupedByCategory = Object.groupBy(
    products,
    (product) => product.category
);
console.log(groupedByCategory);
/* Output:
{
  Electronics: [ 
    { name: 'Laptop', category: 'Electronics' }, 
    { name: 'Mouse', category: 'Electronics' } 
  ],
  Apparel: [ 
    { name: 'T-Shirt', category: 'Apparel' }, 
    { name: 'Jeans', category: 'Apparel' } 
  ]
}
*/

const orders = [
    { id: 1, status: "pending" },
    { id: 2, status: "completed" },
    { id: 3, status: "pending" },
];

const groupedByStatus = Object.groupBy(orders, (order) => order.status);
console.log(groupedByStatus);
/* Output:
{
  pending: [ { id: 1, status: 'pending' }, { id: 3, status: 'pending' } ],
  completed: [ { id: 2, status: 'completed' } ]
}
*/

Each of these methods serves a distinct purpose in the JavaScript developer’s toolkit, offering flexibility in how you construct and manage your objects.


Ensuring Object Safety: Controlling Mutability

When we talk about object safety, we’re typically referring to various levels of immutability or restriction on how an object can be altered after its creation. This is vital for preventing unintended side effects, making your code easier to reason about, and enhancing security.

JavaScript engines manage objects and their properties using internal slots and internal methods (concepts from the ECMAScript specification). Property descriptors, like configurable, writable, enumerable, value, get, and set, directly map to these internal attributes of properties.

The three methods we’re discussing primarily interact with:

Here are the primary methods JavaScript provides:

1. Object.preventExtensions(): No New Properties Allowed

This is the most basic level of restriction. Once an object is preventExtensionsed, you cannot add any new properties to it. You can still modify or delete existing properties, and their configurability remains unchanged.

Example:

const user = { name: "Alice", age: 30 };
console.log("Is extensible before:", Object.isExtensible(user)); // Output: true

Object.preventExtensions(user);
console.log("Is extensible after:", Object.isExtensible(user)); // Output: false

user.city = "New York"; // Attempt to add a new property
console.log(user.city); // Output: undefined (addition failed silently in non-strict mode)
console.log(user); // Output: { name: 'Alice', age: 30 } (no new property added)

user.age = 31; // Modifying an existing property still works
console.log(user.age); // Output: 31

delete user.name; // Deleting an existing property still works
console.log(user.name); // Output: undefined

2. Object.seal(): No New Properties, No Deletion, Existing are Non-Configurable

Sealing an object takes preventExtensions a step further. You still can’t add new properties, but you also can’t delete existing ones. Furthermore, all existing properties become non-configurable, meaning their property descriptors (like writable or enumerable) cannot be changed, and they cannot be converted between data and accessor properties. However, you can still modify the values of existing properties as long as they are writable.

Example:

const config = { host: "localhost", port: 8080 };
console.log("Is sealed before:", Object.isSealed(config)); // Output: false

Object.seal(config);
console.log("Is sealed after:", Object.isSealed(config)); // Output: true
console.log("Is extensible after seal:", Object.isExtensible(config)); // Output: false

config.user = "admin"; // Attempt to add new property -> fails silently
console.log(config.user); // Output: undefined

delete config.host; // Attempt to delete existing property -> fails silently
console.log(config.host); // Output: localhost

config.port = 9000; // Modifying existing property (if writable) -> works
console.log(config.port); // Output: 9000

3. Object.freeze(): The Highest Level of Immutability

Freezing an object provides the strongest level of immutability. It does everything Object.seal() does, and on top of that, it makes all existing data properties non-writable. This means you cannot add, delete, or modify any property of a frozen object. Its direct properties and their values become immutable.

Example:

const constants = { PI: 3.14159, E: 2.718 };
console.log("Is frozen before:", Object.isFrozen(constants)); // Output: false

Object.freeze(constants);
console.log("Is frozen after:", Object.isFrozen(constants)); // Output: true
console.log("Is sealed after freeze:", Object.isSealed(constants)); // Output: true
console.log("Is extensible after freeze:", Object.isExtensible(constants)); // Output: false

constants.G = 9.8; // Attempt to add -> fails
delete constants.PI; // Attempt to delete -> fails
constants.E = 3.0; // Attempt to modify value -> fails

console.log(constants); // Output: { PI: 3.14159, E: 2.718 } (unchanged)

It’s crucial to understand that Object.preventExtensions(), Object.seal(), and Object.freeze() all perform shallow operations. This means they only apply their restrictions to the direct properties of the object you pass to them.

If your object has nested objects or arrays, those nested structures remain mutable and can still be modified, even if the parent object is frozen.

Example of Shallow Freeze:

const shallowFrozen = {
    id: 1,
    data: { value: "original" },
    list: [10, 20],
};

Object.freeze(shallowFrozen);

console.log(Object.isFrozen(shallowFrozen)); // Output: true
console.log(Object.isFrozen(shallowFrozen.data)); // Output: false (nested object is NOT frozen)
console.log(Object.isFrozen(shallowFrozen.list)); // Output: false (nested array is NOT frozen)

// You CANNOT change direct properties of shallowFrozen:
// shallowFrozen.id = 2; // Fails

// But you CAN change properties of nested objects/arrays:
shallowFrozen.data.value = "modified"; // WORKS!
shallowFrozen.list.push(30); // WORKS!

console.log(shallowFrozen.data.value); // Output: modified
console.log(shallowFrozen.list); // Output: [10, 20, 30]

To achieve deep immutability, you would need to recursively traverse the object and apply Object.freeze() to every nested object and array. There are libraries that provide deep freezing utilities, but it’s not a built-in one-liner.


Conclusion

In conclusion, JavaScript objects are far more than just simple collections of key-value pairs. By understanding their foundational concepts—from the basic object literal and the nuances of the this keyword, to the intricate control offered by property descriptors—you gain immense power over your data structures.

We’ve explored diverse creation patterns, including Object.create() for prototypal inheritance, Object.fromEntries() for data transformation, and Object.assign() for merging. Crucially, we’ve delved into object safety, seeing how Object.preventExtensions(), Object.seal(), and Object.freeze() allow you to control mutability at different levels, directly manipulating a property’s configurable and writable attributes. Mastering these techniques is essential for writing robust, maintainable, and predictable JavaScript applications, helping you prevent unexpected bugs and build more resilient systems.