Object Prototypes
JavaScript uses a unique inheritance model called prototype-based inheritance, which stands in contrast to the class-based inheritance found in many other programming languages like C++ and Java. Instead of defining classes as blueprints for generating objects, JavaScript objects are created by making a reference to other objects, known as prototypes. These prototypes act as blueprints from which objects can inherit properties and behaviors.
At the heart of JavaScript's prototype system is the prototype chain. Every object in JavaScript has an internal link to another object, its prototype. This chain of objects continues until it reaches the end, typically the Object.prototype
, which serves as the top of the prototype chain.
This section described how prototyping works, briefly. For the most part, we will be able to focus on writing JavaScript without dealing with the details of prototyping often - however if you truly want to master JavaScript, having a deep understand is valuable.
Creating Objects with Prototypes
When you create an object using object literal notation or the Object.create
method, you are establishing a prototype relationship. The simplest example is an object created with {}
:
const obj = {};
This object has Object.prototype
as its prototype, which gives it access to methods like toString
and hasOwnProperty
. Those methods are implemented on Object.prototype
.
However, if you want to create an object with a different prototype, you can use Object.create
:
const proto = { greet: function() { console.log("Hello!"); }};
const obj = Object.create(proto);
obj.greet(); // Prints "Hello!"
In this example, the object obj
inherits the greet
method from its prototype, proto
. The prototype acts as a fallback — if obj
doesn’t have a property, JavaScript will look for it on the prototype. It's really just a different approach towards inheritance, without creating property types (classes).
Prototype Chain
When accessing a property on an object, JavaScript will first check if the property exists directly on the object. If it doesn't find the property, it will follow the object's prototype chain to search for it. This continues until it either finds the property or reaches the end of the chain.
const animal = { hasTail: true };
const dog = Object.create(animal);
dog.bark = function() { console.log("Woof!"); };
console.log(dog.hasTail); // true (inherited from animal)
dog.bark(); // "Woof!"
Here, dog
does not have a hasTail
property directly, but since animal
is its prototype, the property is found through the prototype chain.
Modifying Prototypes
You can modify prototypes at runtime, and any object linked to that prototype will immediately reflect the change.
const proto = { greet: function() { console.log("Hello!"); }};
const obj = Object.create(proto);
// Adding a new method to the prototype
proto.sayGoodbye = function() { console.log("Goodbye!"); };
obj.sayGoodbye(); // Prints "Goodbye!"
Be cautious when modifying built-in prototypes (such as Object.prototype
), as this can lead to unintended consequences throughout your codebase, since all objects will inherit these changes.
__proto__
and Object.getPrototypeOf()
JavaScript provides two key ways to access an object’s prototype:
- The
__proto__
property, which is widely supported but is non-standard and discouraged in modern code. - The
Object.getPrototypeOf()
method, which is the recommended way to retrieve an object’s prototype.
const obj = {};
console.log(obj.__proto__); // Outputs Object.prototype
console.log(Object.getPrototypeOf(obj)); // Same as above
Setting Prototypes
You can set an object’s prototype using the Object.setPrototypeOf()
method. However, this method is rarely used in practice because modifying an object’s prototype after creation can hurt performance.
const animal = { hasTail: true };
const bird = { canFly: true };
Object.setPrototypeOf(bird, animal);
console.log(bird.hasTail); // true (inherited from animal)
Constructors!
In JavaScript, functions can serve as constructors when invoked with the new
keyword. Constructor functions set up the prototype chain for the objects they create. By default, every function has a prototype
property, which points to an object. When you use a constructor, the newly created object links to the constructor’s prototype.
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function() {
console.log(`${this.name} makes a sound.`);
};
const dog = new Animal("Dog");
dog.speak(); // "Dog makes a sound."
Here, the Animal
constructor sets up dog
's prototype to link to Animal.prototype
. As a result, dog
can access the speak
method.
The this
Keyword in Constructors
When working with object-oriented concepts in JavaScript, the this
keyword plays a central role in defining properties and behaviors that belong to a specific instance of an object. In the context of a constructor function, this
refers to the new object instance being created.
For example:
function Person(name, age) {
this.name = name; // 'this' refers to the new instance
this.age = age;
}
const john = new Person("John", 30);
console.log(john.name); // "John"
console.log(john.age); // 30
In this code:
Person
is a constructor function.this.name = name
assigns thename
parameter to aname
property on the new object.this.age = age
does the same for theage
property.- The
new Person("John", 30)
call creates a new instance ofPerson
, wherethis
inside the constructor refers to thejohn
object.
How this
Behaves with new
When you use the new
keyword with a constructor function:
- A new empty object is created.
- The constructor function is called with
this
bound to that new object. - Any properties or methods assigned to
this
inside the function become part of the new object. - Unless the constructor returns an object explicitly,
this
(the new object) is returned by default.
For example:
function Car(make, model) {
this.make = make;
this.model = model;
this.drive = function() {
console.log(`Driving a ${this.make} ${this.model}`);
};
}
const car1 = new Car("Toyota", "Corolla");
car1.drive(); // Driving a Toyota Corolla
Here, this.make
and this.model
refer to the specific car instance being created, and this.drive
becomes a method attached to that instance.
The Importance of new
with this
When calling a constructor function without new
, the behavior of this
changes dramatically. Instead of referring to a new object, this
might refer to the global object or be undefined
. This can cause unexpected bugs.
For instance:
function Animal(type) {
this.type = type;
}
const dog = Animal("Dog");
console.log(dog); // undefined
console.log(window.type); // "Dog" (in non-strict mode)
Since new
is not used, the constructor does not create a new object, and this
refers to the global object (window
in browsers, undefined
in Node.js). To avoid this confusion, always use new
when calling a constructor function to ensure that this
refers to the new instance.
We will revisit our discussion of this
when we cover proper ES6 Classes in the next section. Not only does the this
keyword have different implications with true classes, but it also is affected by the use of function
and =>
notations as well.
Prototypes vs Classes
In JavaScript, objects inherit properties and methods through a prototype, an object linked to every instance of a constructor function. This prototype-based inheritance allows shared methods across instances via the prototype chain. This is in stark contrast to a language like C++, which uses class-based inheritance, where classes serve as blueprints, and objects (instances) inherit methods and properties directly from a class hierarchy.
Both the prototype style and class-based inheritance can facilitate most of the same object oriented and polymorphic functionality - especially given JavaScript's typing system. That said, there are advantages to the type of class-based inheritance models we see in other languages:
-
Clarity and Structure: Class-based inheritance provides a more formal and structured way to define relationships between objects. We can explicitly define class hierarchies, making the code easier to read and understand - especially given that most programmers are already familiar with this style of programming.
-
Encapsulation: Classes allow for encapsulation of data and behavior. By using access modifiers (like
private
,protected
, andpublic
), class-based languages provide fine-grained control over which parts of an object are accessible outside its scope. There is no notion of this with the original JavaScript prototype implementation. All properties on objects (including objects that are serving as prototypes) are accessible (and mutable). -
Multiple and Interface Inheritance: Many class-based languages support multiple inheritance or interfaces, allowing for more complex and flexible designs. This lets objects inherit from more than one class or follow multiple interface contracts, making them more versatile in complex systems. This isn't possible with prototyping, as each object has one and only one prototype.
Perhaps the biggest benefit of class-based object oriented design is that there is a distinction between the blueprint of a type, and instances. When using prototyping, objects are derived from other objects, and those underlying objects can be changed. As noted above, you can even change (at runtime) the properties of Object
itself - and those changes would cascade (immediately) to every instance of every object in your program - past, present and future! This might sound incredibly powerful (it is), but it's also really dangerous. class-based design allows you to set up the rules of a "type" in an unmodifiable way, in a more declarative style. This is less powerful, but also far easier to manage.
In the 2015 release of EMCAScript (JavaScript) 6, JavaScript received true class-based syntax. Under the hood, it still uses the prototype design, but from a syntactic perspective we can now design object oriented features in a similar manner as other OO languages. This is the focus of the next section.