<< Home

Chapter 8: Objects and Prototypes

Objects are the foundation of JavaScript. Almost everything in the language is built on objects, from arrays and functions to dates and regular expressions. An object is a collection of key–value pairs, where each key is a string (or symbol) and each value can be any data type, including another object or function.

JavaScript’s object system is unique among major languages because it is prototype-based rather than class-based at its core. Although modern syntax allows classes for convenience, under the surface everything still works through prototypes and delegation along a chain of linked objects.

This chapter explores how objects are created, extended, and connected through prototypes. You will learn how to use object literals, control property behavior with descriptors, and understand how prototype delegation defines inheritance in JavaScript. You will also examine how this works within object methods, how to create new objects from existing ones, and how JSON represents data structures in a portable text format.

💡 In JavaScript, objects are dynamic by nature. You can add, remove, or modify properties at any time, even after creation, which makes them both powerful and flexible.

By mastering objects and prototypes, you gain a deeper understanding of how JavaScript actually works under the hood, allowing you to design data structures, manage inheritance, and build maintainable, extensible programs with confidence.

Object literals and property access

Objects in JavaScript are usually created using object literals—a simple, readable syntax that defines key–value pairs inside curly braces. Each key is called a property name, and each value can be of any type, including another object, array, or function.

Creating objects with literals

An object literal is written as a list of property definitions inside braces:

const user = {
  name: 'Robin',
  age: 55,
  isAuthor: true
};

You can create an empty object with {} or use new Object(), though the literal form is preferred for its clarity and conciseness.

const empty = {};
const another = new Object();
💡 Object literals can include commas after each property. The final trailing comma is allowed and often helps with cleaner diffs in version control.

Accessing properties

JavaScript provides two main ways to access object properties: dot notation and bracket notation. Dot notation is the most common and is used when the property name is a valid identifier:

console.log(user.name);  // Robin
console.log(user.age);   // 55

Bracket notation is required when the property name is dynamic (stored in a variable) or contains spaces or other special characters:

const key = 'isAuthor';
console.log(user[key]);          // true

const data = { 'user name': 'Robin' };
console.log(data['user name']);  // Robin

Adding, changing, and deleting properties

Objects are mutable, so you can freely add, modify, or remove properties after creation:

user.country = 'UK';
user.age = 56;
delete user.isAuthor;

console.log(user);
// { name: 'Robin', age: 56, country: 'UK' }
⚠️ Deleting properties can have performance costs in tight loops or large objects. It is sometimes better to set a property to null or undefined if you only need to mark it as unused.

Nested objects and property chains

Objects can contain other objects, creating nested structures for representing more complex data:

const book = {
  title: 'This is JavaScript',
  author: {
    first: 'Robin',
    last: 'Nixon'
  }
};

console.log(book.author.first);  // Robin

Attempting to access a property on undefined or null will cause an error. The optional chaining operator (?.) can safely check nested properties without throwing:

console.log(book.publisher?.name);  // undefined, no error
💡 Optional chaining (?.) and nullish coalescing (??) make working with nested objects safer and more expressive. Together they help avoid long, repetitive checks for undefined or null.

JavaScript Descriptors

Every property in a JavaScript object has an associated set of attributes known as a property descriptor. These descriptors control how the property behaves, such as whether it can be changed, listed in loops, or deleted. Understanding them gives you precise control over object behavior and immutability.

Viewing property descriptors

You can inspect the descriptor of a property using Object.getOwnPropertyDescriptor():

const user = { name: 'Robin' };

const descriptor = Object.getOwnPropertyDescriptor(user, 'name');
console.log(descriptor);

This outputs an object describing the property:

{
  value: 'Robin',
  writable: true,
  enumerable: true,
  configurable: true
}

These three Boolean attributes (writable, enumerable, and configurable) define the property’s mutability, visibility, and flexibility.

writable

The writable flag determines whether the property’s value can be changed. If it is set to false, any attempt to modify the value will silently fail in non-strict mode or throw an error in strict mode:

const user = {};

Object.defineProperty(user, 'id', {
  value: 1001,
  writable: false
});

user.id = 2002;        // silently ignored (or error in strict mode)
console.log(user.id);  // 1001
⚠️ Setting writable: false only affects the property’s value. It does not prevent deleting or reconfiguring the property unless configurable is also false.

enumerable

The enumerable flag determines whether a property appears in loops such as for...in and in methods like Object.keys() or JSON.stringify(). Non-enumerable properties remain accessible but are hidden from iteration:

const user = {};

Object.defineProperty(user, 'secret', {
  value: 'classified',
  enumerable: false
});

user.visible = 'shown';

for (const key in user) {
  console.log(key);              // only "visible" is shown
}

console.log(Object.keys(user));  // ['visible']
console.log(user.secret);        // accessible directly
💡 Built-in object methods (like toString()) are non-enumerable. This is why looping through an object does not list them.

configurable

The configurable flag determines whether a property can be deleted or redefined. Once configurable is set to false, it cannot be changed back, and the property cannot be removed or reconfigured:

const user = {};

Object.defineProperty(user, 'role', {
  value: 'admin',
  configurable: false
});

delete user.role;                                          // has no effect
Object.defineProperty(user, 'role', { value: 'editor' });  // throws error
⚠️ When configurable is false, even writable and enumerable cannot be altered. Use it only when you are certain a property should remain fixed.

Defining multiple properties at once

You can define several properties in one operation using Object.defineProperties(). This is useful when setting detailed descriptors during object initialization:

const book = {};

Object.defineProperties(book, {
  title: {
    value: 'This is JavaScript',
    writable: false
  },
  author: {
    value: 'Robin Nixon',
    enumerable: true
  }
});

console.log(Object.getOwnPropertyDescriptors(book));

Descriptors allow you to design objects with carefully controlled interfaces, making them ideal for secure APIs, library design, or immutable configurations.

Prototype chain and delegation

Every JavaScript object has an internal link to another object known as its prototype. When you try to access a property that does not exist on an object, JavaScript automatically looks for it up the prototype chain. This mechanism is called delegation and it forms the basis of JavaScript’s inheritance model.

The prototype link

You can view or access an object’s prototype using Object.getPrototypeOf(), and set it explicitly using Object.setPrototypeOf() (though doing so dynamically can affect performance):

const parent = { kind: 'parent' };
const child = { name: 'Robin' };

Object.setPrototypeOf(child, parent);

console.log(child.kind);                               // inherited from parent
console.log(Object.getPrototypeOf(child) === parent);  // true

In this example, the child object delegates missing property lookups to its prototype (parent), forming a simple two-link chain.

💡 In developer tools, you will often see a special property named __proto__. It is a legacy accessor for the prototype, equivalent to Object.getPrototypeOf(), but should not be used in production code.

Prototype delegation in action

When JavaScript encounters a property access like child.kind, it checks the object itself first. If the property is not found, it follows the prototype link and continues searching up the chain until it either finds the property or reaches the top, the Object.prototype.

const grandparent = { species: 'human' };
const parent = Object.create(grandparent);
parent.role = 'developer';

const child = Object.create(parent);
child.name = 'Robin';

console.log(child.species);  // found on grandparent
console.log(child.role);     // found on parent
console.log(child.name);     // found on child

The lookup sequence continues up the chain until no prototype remains, at which point JavaScript returns undefined.

The end of the chain

All standard objects in JavaScript ultimately inherit from Object.prototype, which provides fundamental methods like toString(), valueOf(), and hasOwnProperty():

console.log(Object.getPrototypeOf({}) === Object.prototype);  // true
console.log(Object.getPrototypeOf(Object.prototype));         // null

This means that every object has access to those shared methods unless they are shadowed (redefined) further down the chain.

⚠️ Be careful when naming properties that might overlap with built-in ones such as toString or constructor. Overriding them can cause unexpected results or break built-in behavior.

Creating custom prototypes

The most common way to create objects with a specific prototype is using Object.create(). It allows you to define a prototype and optionally provide property descriptors at creation time:

const animal = {
  speak() {
    console.log('Generic sound');
  }
};

const dog = Object.create(animal);
dog.speak(); // Generic sound

dog.speak = function() {
  console.log('Woof!');
};

dog.speak();  // Woof!

In this case, dog delegates to animal for any missing properties, but can override them locally when needed.

How delegation differs from class inheritance

Unlike class-based inheritance found in languages such as Java or C++, JavaScript’s prototype system uses delegation rather than copying. No properties or methods are duplicated since objects simply link to one another dynamically.

const base = { greet() { console.log('Hello'); } };
const derived = Object.create(base);

derived.greet(); // Hello
base.greet = function() { console.log('Hi'); };
derived.greet(); // Hi (delegation reflects the change)

Because delegation is dynamic, changes to a prototype are immediately visible to all objects that inherit from it. This flexibility makes prototypes both powerful and potentially hazardous if modified carelessly at runtime.

💡 Modern JavaScript class syntax (class and extends) is built on top of this same prototype mechanism. Classes simply provide a cleaner, more structured way to define prototype chains.

this binding rules

In JavaScript, the value of this is determined by how a function is called, not where it is defined. Understanding these binding rules is crucial when working with object methods, event handlers, and constructors, because this can change depending on the calling context.

Default binding

When a function is called in the global scope or as a plain function (not attached to an object), this refers to the global object (window in browsers, global in Node.js). In strict mode, however, this becomes undefined instead.

function showThis() {
  console.log(this);
}

showThis();  // window (or undefined in strict mode)
⚠️ Always enable strict mode or use arrow functions to avoid unintentionally binding this to the global object.

Implicit binding (object methods)

When a function is called as a property of an object, this is automatically set to the object before the dot:

const user = {
  name: 'Robin',
  greet() {
    console.log('Hello, ' + this.name);
  }
};

user.greet();  // Hello, Robin

However, if the method is extracted and called separately, the binding is lost:

const greetFn = user.greet;
greetFn(); // undefined or window, depending on mode
💡 The binding depends entirely on the call site. Losing the connection between a method and its object is one of the most common sources of bugs in JavaScript.

Explicit binding with call, apply, and bind

You can manually control what this refers to by using Function.prototype.call(), apply(), or bind(). These methods allow precise context management:

function greet() {
  console.log('Hello, ' + this.name);
}

const user = { name: 'Robin' };
const guest = { name: 'Alex' };

greet.call(user);  // Hello, Robin
greet.apply(guest); // Hello, Alex

const boundGreet = greet.bind(user);
boundGreet();  // Hello, Robin

call() and apply() invoke the function immediately, while bind() returns a new function with this permanently set to the specified value.

Constructor binding

When a function is used with the new keyword, it acts as a constructor. A new object is automatically created and assigned to this inside the function:

function Person(name) {
  this.name = name;
}

const user = new Person('Robin');
console.log(user.name);  // Robin

In this case, this refers to the newly created instance. If the constructor explicitly returns an object, that object replaces the default this.

Lexical binding in arrow functions

Arrow functions behave differently from regular functions because they do not have their own this. Instead, they inherit this from the surrounding scope (lexical binding):

const counter = {
  value: 0,
  start() {
    setInterval(() => {
      this.value++;
      console.log(this.value);
    }, 1000);
  }
};

counter.start();

Here, the arrow function inside setInterval uses the same this as the enclosing start() method, so it correctly refers to counter.

⚠️ Arrow functions cannot be used as constructors and their this value cannot be changed using call(), apply(), or bind().

Binding precedence summary

When multiple binding rules could apply, JavaScript follows a clear order of precedence:

  1. new binding (highest priority)
  2. explicit binding (call, apply, bind)
  3. implicit binding (object method)
  4. default binding (global or undefined)

Arrow functions bypass this order because they always use lexical binding from their defining scope.

💡 To avoid confusion, prefer arrow functions for callbacks and regular functions for object methods or constructors. This ensures this always refers to what you expect.

Creating and assigning objects

JavaScript provides several built-in methods for creating and combining objects. Two of the most important are Object.create() and Object.assign(). The first lets you create an object with a specific prototype, while the second copies properties from one or more source objects into a target object.

Creating objects with Object.create()

Object.create() creates a new object whose internal prototype is set to a given object. This allows precise control over inheritance without using constructor functions or classes:

const animal = {
  speak() {
    console.log('Generic sound');
  }
};

const dog = Object.create(animal);
dog.bark = function() {
  console.log('Woof!');
};

dog.speak();  // Generic sound (from prototype)
dog.bark();   // Woof!

Here, dog inherits from animal. Any properties not found on dog are delegated up the prototype chain to animal.

💡 You can pass a second argument to Object.create() containing property descriptors, allowing you to define properties with full control from the start.
const cat = Object.create(animal, {
  sound: {
    value: 'Meow',
    writable: false
  }
});

console.log(cat.sound);  // Meow
cat.sound = 'Purr';      // Ignored (not writable)

Copying properties with Object.assign()

Object.assign() copies all enumerable own properties from one or more source objects into a target object. It returns the target object after merging:

const defaults = { theme: 'light', fontSize: 14 };
const settings = { theme: 'dark' };

const finalConfig = Object.assign({}, defaults, settings);
console.log(finalConfig);  // { theme: 'dark', fontSize: 14 }

If multiple sources contain the same key, later ones overwrite earlier values. Non-enumerable properties are ignored, and property descriptors are not preserved (only values are copied).

⚠️ Object.assign() performs a shallow copy. If a property holds a nested object, only the reference is copied, not a deep clone of its contents.
const original = { info: { name: 'Robin' } };
const copy = Object.assign({}, original);

copy.info.name = 'Alex';
console.log(original.info.name);  // Alex (same reference)

Combining patterns

Object.create() and Object.assign() can be used together to build objects with inherited behavior and custom overrides:

const baseUser = {
  role: 'user',
  describe() {
    console.log(this.name + ' (' + this.role + ')');
  }
};

const admin = Object.create(baseUser);
Object.assign(admin, { name: 'Robin', role: 'admin' });

admin.describe();  // Robin (admin)

This pattern is lightweight and flexible. It avoids the need for classes or constructors while still providing inheritance and customization through simple composition.

💡 Both Object.create() and Object.assign() form the foundation of many modern design patterns, such as mixins and prototype-based factories. They offer a clean, low-level way to model relationships and behavior in JavaScript.

JSON as a data format

JSON (JavaScript Object Notation) is a lightweight, text-based format for storing and transmitting data. It is based on JavaScript object syntax, but it is used independently of the language. JSON has become a universal standard for data exchange between applications, APIs, and databases.

Structure of JSON

JSON represents data as key–value pairs enclosed in curly braces, much like a JavaScript object literal. Keys must always be strings wrapped in double quotes, and values can be strings, numbers, booleans, arrays, objects, or null:

{
  "name": "Robin",
  "age": 55,
  "isAuthor": true,
  "books": ["This is Python", "This is JavaScript"],
  "details": { "country": "UK" }
}

Although JSON looks like JavaScript, it is purely data and it cannot include functions, comments, or special values such as undefined.

⚠️ JSON requires double quotes around both property names and string values. Single quotes and unquoted identifiers are invalid.

Converting objects to JSON

To convert a JavaScript object into a JSON string, use JSON.stringify(). This is commonly done when sending data over a network or saving it to a file:

const user = {
  name: 'Robin',
  age: 55,
  active: true
};

const json = JSON.stringify(user);
console.log(json);  // {"name":"Robin","age":55,"active":true}

You can also control how objects are stringified by providing a replacer function or an array of property names, as well as a number of spaces for readable formatting:

console.log(JSON.stringify(user, null, 2));
💡 When debugging or logging structured data, use JSON.stringify(object, null, 2) for a neatly formatted output.

Parsing JSON back into objects

To convert a JSON string back into a JavaScript object, use JSON.parse():

const text = '{"name":"Robin","age":55}';
const obj = JSON.parse(text);

console.log(obj.name);  // Robin

Parsing invalid JSON will throw a SyntaxError, so it is good practice to wrap JSON.parse() in a try...catch block when handling data from unreliable sources.

try {
  const data = JSON.parse(invalidText);
} catch (e) {
  console.error('Invalid JSON data');
}

JSON and data interchange

Because JSON is language-independent, it is supported by almost every programming environment (and is even a useful way to convey complex instructions to generative AI). APIs and web services typically send and receive data in JSON format, which can easily be parsed or generated by both client and server applications.

// Example of sending JSON data with fetch
fetch('/api/user', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Robin', active: true })
});

JSON’s simplicity and portability have made it the de facto standard for configuration files, REST APIs, and structured data storage in both browsers and servers.

⚠️ JSON is strictly textual. It does not preserve object methods, prototypes, or circular references. When serializing complex objects, only data that can be represented in plain JSON types will survive conversion.

JSON bridges the gap between JavaScript objects and universal data exchange formats. Using JSON.stringify() and JSON.parse(), you can serialize and deserialize data with ease, enabling communication across systems, services, and platforms in a consistent, language-neutral way.