Very basic introduction to JavaScript prototypes and inheritance
Mad ramblings
I recently looked for a
basic tutorial on JS prototypes and inheritance for a friend. I was shocked to find that
even 'basic' tutorials go deep into technical details not needed for general use, yet breeze over (or just skip) the
fundamentals. Now, don't get me wrong: in-depth technical details are very important, but (IMO) they cause nothing but
confusion when starting out with JS as your first language. So I decided to write my own tutorial.
This tutorial will focus on fundamentals. When you're ready, I recommend you look at more in-depth details such as on
MDN
and
TutorialsTeacher.
I can also recommend
Google's JavaScript Style Guide.
Introduction
The purpose of JS prototypes is to provide a means of inheritance (also known as code reuse). Inheritance is a tool used
to drastically reduce copy-paste of code. In other words, if you have two or more parts of the application that do
similar things, you may use prototyping to write the code once and dynamically reuse that code many times.
There are various approaches to prototyping; I will demonstrate the methods I believe to be the cleanest, most used, and
most generally applicable.
For our example we'll be making fruits. Because fruits are simple. (unless it's guava... guava is never simple.)
The problem
You want to create a mango (major component of the application). Maybe also an apple. Management may request a banana at
a later stage, but you're unsure.
You
could (shouldn't) do something like this:
var apple1 = {
colour: 'red',
weightKg: 0.179
};
var apple2 = {
colour: 'red',
weightKg: 0.183,
age: 32,
increaseAgeBy: function(days) {
this.age += days;
}
};
var mango = {
colour: 'orange-yellow',
weightKg: 0.151
};
apple2.increaseAgeBy(1);
console.log(apple2.age); // prints '1'
The above is perfectly fine for one-off objects, but not good if you require multiple renditions of this throughout your
application.
The issue here is that you need to copy-paste the code every time you need it. This creates multiple problems:
-
If management are assholes and decide to change what fruits are or what they do (likely), and you have 17 different
fruits, then you'll need to manually find all 17 uses. You will need to update them individually and find all 100's of
places the application refers to those fruits. This requires a lot of effort and almost always introduces bugs that are
inconsistent between fruits.
-
If the the first fruit that you copy-pasted code from had a bug, and you copy-pasted that many times, and then
proceeded to modified some of the other fruits with extra functionality before you realised it was buggy, you would then
need to figure out how to fix the same bug multiple times implemented in different ways. This problem is severe enough
that it can end a project.
On to better use of grammar.
The solution
Instead of referring to the core code as 'apple' or 'pear', we may instead refer to it as 'fruit' outright. The fruit
object would define the common characteristics that
all fruit have.
We then create our apple, which inherits all basic fruit stuff from the fruit object.
Let's get right to it.
// First, we define fruit generally. We use a function to do this. De facto
// convention is to capitalise function names if we intend to use them for
// inheritance. Functions used in this way are also referred to as
// constructors, or base objects.
function Fruit() {
// We use this
to inform the JS engine that we're specifically storing
// these variables inside whatever Fruit
we end up making.
this.colour = '';
this.age = 0;
this.weightKg = 0;
}
// All fruit have the capability to age, so we define this as part of the
// prototype. The 'prototype' property is a special internal that JavaScript
// uses for this purpose.
Fruit.prototype.increaseAgeBy = function(days) {
this.age += days;
};
// This is where the magic happens. We can create the new fruit from our Fruit
// object.
var apple1 = new Fruit();
var apple2 = new Fruit();
var mango = new Fruit();
// Our apples and mangos have inherited Fruit functionality and properties,
// which we may now use.
console.log(apple1.age); // prints '0'
apple1.increaseAgeBy(7);
console.log(apple1.age); // prints '7'
console.log(mango.age); // prints '0'
mango.increaseAgeBy(1);
mango.increaseAgeBy(3);
console.log(apple1.age); // prints '4'
We can now use our Fruit object to manage all our fruit.
In this case we refer to the apples and mangos as instances of Fruit.
I use Fruit
to refer to the Fruit function, and 'fruit' to just talk about fruit in a general
sense. This style will be followed throughout the document.
Constructor parameters
Following from the above, we may now do something like this:
// Initialise our third apple.
var apple3 = new Fruit();
apple3.colour = 'red';
apple3.age = 3;
apple3.weightKg = 0.123;
There are however nicer ways of working with this, namely: construction parameters. Parameters are an optional
alternative to making initialisation more concise.
Let's start by rewriting our
Fruit
function:
// Add the options
parameter, which we can then use to set up our object:
function Fruit(options) {
this.colour = options.colour;
this.age = options.age;
this.weightKg = options.weightKg;
}
We can now create our fruit like so:
// Initialise our third apple.
var apple3 = new Fruit({
colour: 'red',
age: 5,
weightKg: 0.1,
});
// The old code still works:
console.log(apple3.age); // prints '5'
apple3.increaseAgeBy(3);
console.log(apple3.age); // prints '8'
Accessors and validation
Instead of directly accessing properties it's generally a good idea to, loosely speaking, 'hide' variable properties. I
put 'hide' in quotes because we technically still have full access to them, but general convention is to pretend
such properties don't exist when using
Fruit
elsewhere. This is done to make the lives of other people using
Fruit
in their code a little easier because it allows those people to ignore the way it works internally while
still using
Fruit
to its full potential.
We call these 'hidden' properties private properties.
You, the creator of the code, can also be though of as 'other' people because in time people tend to
forget how they wrote their own code. The idea is to make your life as well as the lives of others a
little easier.
Once we've made each property private we'll write functions to read from and write to them. This has the added advantage
that we may add things like validation (or other functionality) if we wanted to, as we'll see in a minute.
An example of why we may want validation is as follows. You may have noticed an issue above: nothing stops us
accidentally storing bad values. I could do something like this:
apple3.age = 'three, I think?'; // <-- this should be a number..
apple3.colour = true; // <-- and this should be a string.
We make properties private by putting an underscore in front of them. We can then write prototype functions to
read/write them, and check for obvious problems while we're at it:
function Fruit(options) {
// Make our internals private.
this._colour = options.colour;
this._age = options.age;
this._weightKg = options.weightKg;
}
// With that done, let's write prototypes to access them.
// Returns fruit colour, or undefined
if not set.
Fruit.prototype.getColour = function() {
return this._colour;
};
// Because we're accessing properties via function calls, we can
// use our set functions to validate user input before storing it.
// This prevents users them from accidentally doing something silly.
Fruit.prototype.setColour = function(colour) {
// Ensure the user is actually passing us a string:
if (typeof colour !== 'string') {
throw 'setColour requires a string.';
}
else {
this._colour = colour;
}
};
// Returns fruit age, or undefined
if not set.
Fruit.prototype.getAge = function() {
return this._age;
};
// Not only can we ensure we're being passed the right type of
// variable, but we can also do some sanity checking, like making
// sure our age is a positive number.
Fruit.prototype.setAge = function(age) {
if (typeof age !== 'number' || age < 0) {
// Prevent bad value.
throw 'Error: setAge requires a number, and it needs to be positive.';
}
else {
// Set age as requested.
this._age = age;
}
};
// Returns fruit weight in kilograms, or undefined
if not set.
Fruit.prototype.getWeightKg = function() {
return this._weightKg;
};
// We can also check if the values seem reasonable. For example, is
// a 50kg weight normal, or are we possibly confusing grams with
// kilograms?
Fruit.prototype.setWeightKg = function(weight) {
if (typeof weight !== 'number' || weight < 0) {
// Prevent bad value.
throw 'Error: setWeightKg requires a number, and it needs to be positive.';
}
else {
// Set age as requested.
this._weightKg = weight;
}
// Warn if weight seems weird:
if (weight > 50) {
console.warn('Fruit weight set to ' + weight + 'kg - is this correct?');
}
};
Now that we're using functions to access our properties, we might use write our
implementation as follows:
var apple1 = new Fruit({
colour: 'red',
age: 5,
weightKg: 0.1
});
apple1.setAge(3); // works as expected.
apple1.setAge('three'); // Uncaught Error: setAge requires a number, and it
// needs to be positive.
Only one more thing remains: validation in our constructor. We could still do
something invalid like:
var apple1 = new Fruit({
age: 'lol 3 apples.'
});
// ^^ no errors are thrown if we do this.
We can fix this by simply referencing our set functions from the constructor:
function Fruit(options) {
// This insures we use our sanity checkers.
this._colour = this.setColour(options.colour);
this._age = this.setAge(options.age);
this._weightKg = this.setWeightKg(options.weightKg);
}
Creating a fruit with invalid options will now fail:
var apple1 = new Fruit({
age: 'two days'
});
// ^^ Uncaught Error: setAge requires a number, and it needs to be positive.
With the above section done, we're done with the basics. You now know enough to start using prototypes in your daily
life.
However, if you feel like going just a little further and doing something magical (albeit a tad more advanced), I'd
like to introduce you to:
Getters and setters
The above methods will work well, but things are now a little.. verbose. Wouldn't it be nice if instead of the above
implementation, we could simply use our code this way:
var mango = new Fruit({
age: 3
});
// Assign directly instead of using a function.
mango.age = 'orange';
// ^^ Uncaught Error: Fruit age should be a (positive) number.
As it turns out, you can do exactly that. You can achieve this by adding special functionality to properties. You do
this via special functions called getters and setters.
Getter/setter syntax looks like this:
function Vegetable() {
this._color = null;
this._taste = 'bad';
}
Vegetable.prototype = {
get name() {
return this._name;
},
set name(value) {
this._name = value;
},
get taste() {
return this._taste;
},
set taste(value) {
if (value !== 'bad') {
throw 'Error: all vegetables taste bad.';
}
}
};
With the above, we can then use our object as follows:
var celery = new Vegetable();
celery.colour = 'green'; // This works fine.
celery.taste = 'good';
// ^^ Uncaught Error: all vegetables taste bad.
Notice how celery (obviously) cannot taste good, so our code throws an error if we try to tell it lies.
We can apply the same pattern to our fruit:
/**
* Fruit object. Used to (sometimes) create sweet things.
* @param {object} options - Optional.
* @constructor
*/
function Fruit(options) {
// Check if we've been given an options object. If not, create an empty one.
// Doing so makes coding a tad easier as we need less error checking code.
if (typeof options === 'undefined') {
options = {};
}
// Next, we assign default values for our private variables:
this._colour = 'unknown';
this._age = 0;
this._weightKg = 0;
// Now, let's process the options
object and apply any options the
// user asked for. We do this by looping through it and assigning values to
// our fruit instance along the way.
for (var property in options) {
if (options.hasOwnProperty(property)) {
this[property] = options[property];
// ^^ Example of what this looks like to the computer:
// this['color'] = options['colour'];
}
}
}
// Next we write our getters and setters. The contents of our
// getter / setter functions look pretty much exactly like our
// previous prototype functions.
Fruit.prototype = {
/**
* Returns fruit colour.
* @returns {string}
*/
get colour() {
return this._colour;
},
/**
* Set fruit colour.
* @param {string} value - Any valid CSS colour name.
*/
set colour(value) {
// Ensure the user is actually passing us a string or null:
if (typeof value !== 'string' && value !== null) {
throw 'Error: Fruit colour may only be null or a string.';
}
else {
this._colour = value;
}
},
/**
* Return the fruit's age, in days.
* @returns {number}
*/
get age() {
return this._age;
},
/**
* Sets the fruit age.
* @param {number} age - Fruit age, in days.
*/
set age(age) {
if (typeof age !== 'number' || age < 0) {
throw 'Error: Fruit age should be a (positive) number.';
}
else {
this._age = age;
}
},
/**
* Gets the fruit weight, in kilograms
* @returns {number}
*/
get weightKg() {
return this._weightKg;
},
/**
* Sets the fruit weight.
* @param {number} weight - Fruit weight, in kilograms.
* @returns {number}
*/
set weightKg(weight) {
if (typeof weight !== 'number' || weight < 0) {
throw 'Error: Fruit weigh should be (positive) number.';
}
else {
this._weightKg = weight;
}
// Warn if weight seems weird:
if (weight > 50) {
console.warn('Fruit weight set to ' + weight + 'kg - is this correct?');
}
}
};
// Finally, we add any additional prototype functions we need.
/**
* Increases the fruit's age.
* @param {number} days - Amount of time to increase fruit age by, in days.
*/
Fruit.prototype.increaseAgeBy = function(days) {
this.age += days;
};
/**
* Kills the fruit. Colour is set to brown to reflect this.
*/
Fruit.prototype.kill = function() {
this.colour = 'brown';
};
The /** [stuff] */
comments are called JSDoc. It's a method of documenting code that is both human readable
and parsable by IDEs. Using JSDoc will improve code autocompletion on any decent IDE.
Our fruit class is now production ready. We may define our fruits as needed, fully features with error checking and the
like:
// Test initial style:
var apple = new Fruit();
apple.colour = 'red';
apple.increaseAgeBy(9);
console.log(apple.age); // 9
// Test constructor arguments:
var mango = new Fruit({
colour: 'orange-yellow',
weightKg: 0.151
});
console.log(mango.weightKg); // 0.151
// Test setting an invalid age:
mango.age = 'orange';
// ^^ Uncaught Error: Fruit age should be a (positive) number.
// Test passing a bad value to the constructor:
var badFruit = new Fruit({ colour: true });
// ^^ Uncaught Error: Fruit colour may only be null or a string.
Our
Fruit
instance is now functional, easy to use, and has built-in error
checking.
Well done on making it to the end of the tutorial! Many great success and happy coding to you.
- aggregate1166877