JavaScript object-orientated and prototype-based programming
As you learned in the previous chapter on JavaScript data types, JavaScript relies heavily on the concept of objects. While being familiar with other object-orientated programming(OOP) languages (e.g. C#, Java) helps, JavaScript works differently than most mainstream OOP languages.
JavaScript was conceived as a prototype-based programming language, which is a type of OOP language. Although the differences are subtle, exploring prototype-based concepts is critical to better understanding JavaScript in general, not to mention knowing such differences also makes it easier to grasp JavaScript's object-orientated evolution.
Prototype-based programming and the prototype
property
A prototype, according to the dictionary is: an original model on which something is patterned. By extension, it means prototype-based programming languages use prototypes as their building blocks. But re-read the prototype definition again, doesn't it sound pretty similar to the concept of classes in OOP ? After all, in OOP languages, classes are used to describe models that describe how a system works.
It turns out OOP and prototype-based programming are behaviorally similar, however, prototype-based design is a little different than class-based design. To make your first encounter with prototype-based design in JavaScript as simple as possible, I'll start with a scenario based on the String
data type presented in the previous chapter.
The JavaScript String
data type has its own set of properties and methods provided out-of-the-box, which are made available to string
primitives and instances of the String
data type, as shown toward the end of listing 4-2.
Now, suppose you're faced with the problem of constantly formatting the strings in your application with a variation that isn't provided by the String
data type (e.g. adding exclamation marks or wrapping it as HTML bold statements). In an OOP language, to simplify this repetitive process you would create a String
sub-class -- to inherit the out-of-the-box String
properties and methods -- and be able to add custom properties or methods to perform these repetitive operations. In a prototype-based programming language, specifically JavaScript, all you need to do is alter the String
data type itself via its prototype
property to make new methods accesible to all String
instances, a process that's illustrated in listing 5-1.
Note In JavaScript all objects have properties. An object property assigned a function()
statement is referred to as a method, whereas an object property assigned a literal value (e.g. a string, a number) is referred to as a property. Therefore the properties term can be used in a broad or narrow sense, the former in reference to both methods and properties with literal values, the latter in reference to properties with literal values.
Listing 5-1. String
data type with new methods added via the prototype
property.
String.prototype.wildExclamation = function() { return `${this.toString()}!#?!#?`; }; String.prototype.boldHtml = function() { return `<b>${this.toString()}</b>`; }; // string primitive with literal string let hello = "Hello"; // string access to built-in String method console.log(hello.toUpperCase()); // string access to custom String methods added via prototype console.log(hello.wildExclamation()); console.log(hello.boldHtml()); // string primitive with String constructor as plain function let world = String("World"); // string access to built-in String method console.log(world.toUpperCase()); // string access to custom String methods added via prototype console.log(world.wildExclamation()); console.log(world.boldHtml());
The first two snippets in listing 5-1 add the wildExclamation()
and boldHtml()
methods to the built-in String
data type using the prototype
property.
The additional methods added to the String
data type in listing 4-1 make use of the this
keyword to access the current instance of a string and then leverage the String.toString()
method to access its actual value. So for the let hello = "Hello";
statement, a call to this.toString()
with the hello
string instance returns "Hello"
. In both cases, the methods make use of template literals with backticks `
and ${}
to wrap each value with custom markup, in the case of wildExclamation()
adding the trailing characters !#?!#?
and for boldHtml()
wrapping the content in an HTML <b> element.
Once a data type's prototype
is updated, all instances of the data type gain access to these new functionalities. You can see in listing 5-1 both the hello
and world
string primitives are able to access the standard String.toUpperCase()
method included with the String
data type, as well as the custom wildExclamation()
and boldHtml()
methods added via the prototype
property.
Now that you have an initial understanding of the JavaScript prototype
property with the String
data type, lets take a look at the prototype chain.
Prototype-based programming and the prototype chain
The past section illustrated how special the prototype
property is to JavaScript data types, since it allows adding functionalities to a data type to become available to all object instances of the data type. Unlike object-orientated languages that use classes to define inheritance hierarchies by extending other classes or use other constructs like abstract classes and interfaces to build secondary classes, in a prototype-based programming language like JavaScript, the inheritance process is much simpler and achieved through the prototype
property.
You already learned a little bit about the prototype
property in the example in listing 5-1, that adds a couple of custom methods to the built-in String
data type so they're accesible to all String
object instances. Now, we're going to add a custom property to the more generic Object
data type, so it becomes accesible to all Object
object instances, as well as data types that have the Object
data type in their prototype chain.
Listing 5-2. Prototype chain, property resolution and shadowing
let javascript = new Object(); console.log("javascript is: %o", javascript); console.log("javascript.typed is: %o", javascript.typed); // Add "typed" property to Object.prototype Object.prototype.typed = "dynamically"; // Pre-existing Object objects gain access to "typed" property console.log("javascript is: %o", javascript); console.log("javascript.typed is: %o", javascript.typed); // New Object objects get "typed" property let python = new Object(); console.log("python is: %o", python); console.log("python.typed is: %o", python.typed); let c = new Object(); console.log("c is: %o", c); console.log("c.typed is: %o", c.typed); // Override "typed" property by adding it directly to object c.typed = "statically"; // Object object "typed" property belongs to instance console.log("c is: %o", c); console.log("c.typed is: %o", c.typed); // Create string primitive / String object let hello = "Hello"; console.log("hello is: %o", hello); // String object has access to "typed" property added to Object.prototype console.log("hello.typed is: %o", hello.typed); // String can shadow the Object.prototype "typed" property with its own String.prototype String.prototype.typed = "N/A"; console.log("hello.typed is: %o", hello.typed);
Listing 5-2 starts with the creation of a new Object
assigned to the javascript
reference. Next, you can see that attempting to output the typed
property on the javascript
reference shows undefined
, since the Object
instance doesn't know anything about this property.
Then you can see the statement Object.prototype.typed = "dynamically";
adds the typed
property with a value of "dynamically"
to the Object
data type's prototype
property, effectively making the typed
property avaiable to all Object
instances. The next console
statements show two important behaviors for properties added through the prototype
property:
- Properties added to the
prototype
property aren't part of object instances. Notice the output for thejavascript
object instance is empty and doesn't include any properties. - Properties added to the
prototype
property are accesible to object instances because they belong to a data type. Notice you can output the value of thejavascript.typed
property, so long as you know it exists as part of the object's data type.
Next in listing 5-2, a pair of new Object
instances are created and assigned to the python
and c
references. For both references you can see that attempting to output the typed
property outputs dynamically
, confirming the new objects automatically get the typed
property from the Object
data type's prototype
property. However, notice it's also possible to override a prototype
property by adding a property with the same name directly to the object instance, in this case, the c
object overrides the typed
property value with the c.typed = "statically";
statement.
The most interesting aspect in listing 5-2 though is the creation of the String
object -- via a string
primitive and literal "Hello"
-- which based on the console
statements also has access to the typed
property with a value of dynamically
. The reason a string
primitive -- which automatically gains access to the functionalities in the String
data type -- is because the String
data type has a prototype chain linked to the Object
data type. In addition, notice it's also possible to shadow a prototype
property of an upstream data type in the prototype chain by adding a property to the prototype
property to the working data type. In this case, the statement String.prototype.typed = "N/A";
, shadows the Object
data type's prototype typed
property with a different prototype typed
property value for the String
data type applicable to all String
objects.
As you can see from the examples in listing 5-2, there's a lot more going on behind the scenes when JavaScript attempts to resolve object properties, which is directly related to the prototype chain. The resolution process is as follows:
- An object property is first looked up on the object instance itself.
- If it isn't available, it's looked up next on the object's data type
prototype
. - If it isn't available, it's looked up next on the object's upstream prototype data type
prototype
. - And so on, until the end of an object's prototype chain is reached with
null
.
It's thanks to this prototype chain behavior, most objects are able to access properties and methods from upstream data types in their prototype chain or override such properties and methods from their own working data type. It's also the reason why most built-in data type properties and methods are documented under the prototype
property (e.g. <data_type>.prototype.<property_name>
) to indicate they're part of a data type's prototype.
There's no single call to get the prototype chain from a given object, but you can get the prototype of a given object, followed by the prototype of the prototype and the prototype of the prototype's prototype and so on, to net you an object's prototype chain. There are two ways to obtain the prototype for a given object, one is through the standard Object.getPrototypeOf()
static method and the other through the private object reference __proto__
. Both approaches deliver the same results, it's simply the __proto__
property is a browser maker implemented syntax that isn't guaranteed to always be available, whereas the Object.getPrototypeOf()
static method is a standard ECMAScript construct.
Tip See the Do JavaScript objects have other encapsulation/accessibility constructs besides getters and setters ? section, for additional details on the use of references that use underscores _
Listing 5-3 illustrates how to access an object's prototype and build its prototype chain using both __proto__
and Object.getPrototypeOf()
, as well as where the plain .prototype
property fits in the context of the prototype chain.
Listing 5-3. Prototype chain determination with __proto__
and Object.getPrototypeOf()
let vowels = ["a","e","i","o","u"]; console.log("Object.getPrototypeOf(vowels)", Object.prototype.toString.call(Object.getPrototypeOf(vowels))) console.log("Object.getPrototypeOf(Object.getPrototypeOf(vowels))", Object.prototype.toString.call(Object.getPrototypeOf(Object.getPrototypeOf(vowels)))) console.log("Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(vowels)))", Object.prototype.toString.call(Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(vowels))))) console.log("vowels.__proto__: %s", Object.prototype.toString.call(vowels.__proto__)); console.log("vowels.__proto__.__proto__: %s", Object.prototype.toString.call(vowels.__proto__.__proto__)); console.log("vowels.__proto__.__proto__.__proto__: %s", Object.prototype.toString.call(vowels.__proto__.__proto__.__proto__)); if ((Object.getPrototypeOf(vowels) === vowels.__proto__) && (vowels.__proto__ === Array.prototype)) { console.log("(Object.getPrototypeOf(vowels) === vowels.__proto__) && (vowels.__proto__ === Array.prototype)"); } if (vowels.__proto__ != vowels.prototype) { console.log("Array.prototype != vowels.prototype"); }
The first statement in listing 5-3 declares a JavaScript Array
object in the vowels
reference. Next, you can see the statement Object.getPrototypeOf(vowels)
outputs the vowels
prototype. Since the vowels
reference and its prototype are objects, we wrap the output around Object.prototype.toString.call()
to get a specific data type object value, as described in listing 4-9.
As expected the output of Object.getPrototypeOf(vowels)
is [object Array]
, indicating the reference's prototype is an Array
data type. To get the upstream prototype of the Array
data type another Object.getPrototypeOf()
statement is wrapped around the first, which outputs [object Object]
, indicating the prototype's prototype is an Object
data type. And finally a third Object.getPrototypeOf()
statement is wrapped around the initial two to get the prototype's prototype prototype, which outputs [object Null]
, indicating the end of the prototype chain.
The second set of console
statements in listing 5-3 also outputs the vowels
object prototype chain, but instead of using the Object.getPrototypeOf()
static method it uses the object's .__proto__
property. The conditionals that follow confirm the results of the Object.getPrototypeOf()
static method and an object's .__proto__
property are identical.
Finally, another key concept shown in listing 5-3 is the .prototype
property in the context of the prototype chain. Notice the results of the Object.getPrototypeOf()
static method and an object's .__proto__
property are equal to the object's data type .prototype
property, in this case Array.prototype
. Also notice accesing the .prototype
property on the object instance itself vowels.prototype
outputs undefined
. This behavior is because the .prototype
property is intended to be used on data types -- like it's done in listing 5-1 and listing 5-2. When the .prototype
property is used on object instances, it's treated like any other property, so the vowels.prototype
output is undefined
since the object doesn't know anything about this property. In conclusion, an object instance knows about its prototype, but only through the Object.getPrototypeOf()
static method and its own .__proto__
property, the .prototype
property is not intended for object instance use.
Now that you have an initial understanding of the JavaScript prototype
property and the prototype chain, lets take a look at another JavaScript prototype-based programming concept: constructor functions.
Prototype-based programming constructors & static properties, the early years
A constructor as its name implies is used to build something. In OOP and prototype-based programming, a constructor is what's used to build object instances from a data type.
Up to this point, you've worked with various constructors from JavaScript's built-in data types (e.g. String()
, Date()
). However, the thing about constructors offered by built-in data types is they're too versatile, since some can be used as constructors, as well as plain functions, something that can make it hard to pin down what to expect from pure JavaScript constructors.
In order to truly grasp how JavaScript constructors work, I'm going to start with a clean slate using a custom JavaScript data type called Language
that is its own constructor. Listing 5-4 illustrates the internal workings of constructor on a custom data type.
Listing 5-4. Constructor functions for custom data types
// Constructor function var Language = function(name,version) { //"use strict"; // prevents access to 'this' if not local console.log("'this' execution context at constructor function start: %s", this); this.name = name; this.version = version; this.hello = function() { return `Hello from ${this.name}`; } }; // Static property, added to constructor Language.KIND = "HighLevel"; // Create object instances with new let javascript = new Language("JavaScript","2022"); let python = new Language("Python","3.10"); // Verify instance values console.log("javascript type: %s", typeof javascript); console.log("javascript.name: %s", javascript.name); console.log("javascript.version: %s", javascript.version); console.log("Object.getPrototypeOf(javascript): %s", Object.getPrototypeOf(javascript)); console.log("Object.getPrototypeOf(Object.getPrototypeOf(javascript)): %s", Object.getPrototypeOf(Object.getPrototypeOf(javascript))); console.log("Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(javascript))): %s", Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(javascript)))); console.log("Object.getPrototypeOf(javascript.constructor): %s", Object.getPrototypeOf(javascript.constructor)); console.log("Object.getPrototypeOf(Object.getPrototypeOf(javascript.constructor))): %s", Object.getPrototypeOf(Object.getPrototypeOf(javascript.constructor))); console.log("Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(javascript.constructor)))): %s", Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(javascript.constructor)))); console.log("python type: %s", typeof python); console.log("python.name: %s", python.name); console.log("python.version: %s", python.version); console.log("python.hello(): %s", python.hello()); console.log("Object.getPrototypeOf(javascript): %s", Object.getPrototypeOf(python)); console.log("Object.getPrototypeOf(Object.getPrototypeOf(python)): %s", Object.getPrototypeOf(Object.getPrototypeOf(python))); console.log("Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(python))): %s", Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(python)))); console.log("Object.getPrototypeOf(python.constructor): %s", Object.getPrototypeOf(python.constructor)); console.log("Object.getPrototypeOf(Object.getPrototypeOf(python.constructor))): %s", Object.getPrototypeOf(Object.getPrototypeOf(python.constructor))); console.log("Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(python.constructor)))): %s", Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(python.constructor)))); // Call constructor as a plain function without new let ruby = Language("Ruby","3"); console.log("ruby type: %s", typeof ruby); console.log("ruby.name: %s", ruby && ruby.name ? ruby.name: undefined); console.log("ruby.version: %s", ruby && ruby.version ? ruby.version: undefined); console.log("ruby.hello(): %s", ruby && ruby.hello() ? ruby.hello(): undefined); console.log("Object.getPrototypeOf(ruby): %s", ruby && Object.getPrototypeOf(ruby) ? Object.getPrototypeOf(ruby): undefined); console.log("ruby.constructor: %s", ruby && ruby.constructor ? ruby.constructor: undefined); // Get Language static property 'KIND' console.log("Language.KIND: %s", Language.KIND); // Static properties aren't available in object instances console.log("javascript.KIND: %s", javascript && javascript.KIND ? javascript.KIND: undefined); console.log("python.KIND: %s", python && python.KIND ? python.KIND: undefined); console.log("ruby.KIND: %s", ruby && ruby.KIND ? ruby.KIND: undefined);
Initially the var Language
statement in listing 5-4 can appear like a plain function expression -- presented in first class functions: Function declarations, function expressions, hoisting & undefined
-- but it's actually a constructor due to its characteristics:
- A constructor is a
function
that lacks areturn
statement. - A constructor is a
function
that uses thethis
keyword to update its object instance. - A constructor is a
function
whose name uses title case convention.
So the var Language
statement in listing 5-4 is a constructor because of its characteristics. In this case, the this
keyword is used to reference the object's instance and assign it the name
and version
properties with values from the constructor's input also called name
and version
, as well as give the object instance a hello()
method that returns a string based on the object's instance name
property. In addition, notice after the constructor definition, the KIND = "HighLevel";
property is added directly to the Language
constructor to work as a static property, which by convention, uses all uppercase letters. By being a static property, it means the property doesn't change across instances of the Language
data type.
Although the syntax characteristic of Language
make it a constructor, because it's still a function expression, it's entirely valid to call the constructor in one of two ways: with the new
keyword or without it as a plain function. The most important side effects of calling a function expression with the new
keyword are:
- Using the
new
keyword on a constructor automatically provides the object with its prototype & prototype chain. - Using the
new
keyword on a constructor automatically creates a new execution context accessible through thethis
reference to update the object instance inside the constructor. - Using the
new
keyword on a constructor automatically returns thethis
reference from the constructor once it's finished.
That said, let's take a closer look at the differences of using and not using the new
keyword as they're presented in listing 5-4.
The new Language("JavaScript","2022");
and new Language("Python","3.10");
statements create two different Language
instances. You'll notice that when each instance is created, the first log statement in the constructor function outputs the this
reference as a Language
object, confirming a dedicated execution context for the constructor. In the log statements after the instances are created, you can also see the reference for each instance is a typeof
object
, confirming the constructor automatically returns the object instance even though it lacks an explicit return
statement. In addition, in the remaining log statements, you can also see: the output for the name
& version
properties and hello()
method for each instance is output based on the constructor inputs; the prototype output for both objects is the Language
data type, with a prototype chain Language - Object - null
; and both objects have a constructor
property -- which points to the constructor function -- that itself has a prototype output of the Function
data type, with a prototype chain Function - Object - null
.
The Language("Ruby","3");
statement calls the constructor as a plain function. The first thing that's different from making a call without the new
keyword, is the log statement in the constructor function outputs the this
reference as an Object
object, confirming the execution context inside the function is pointing toward the outer this
reference -- in this case the global Object
-- a behavior described in the lexical scope and the this
execution context section. This behavior is not only wrong but dangerous, since the this
statements inside the constructor are influencing properties and methods in the outer scope, potentially overwriting them or adding them where they aren't needed, in this case the global Object
. To disallow this behavior of accessing the this
reference from another execution context, declaring the "use strict"
at the beginning of the function -- commented out in the example -- generates an error when trying to access a this
reference, because the constructor won't have a reference to this
unless the constructor is called with the new
keyword, which essentially forces the constructor to only work when called with the new
keyword.
You can also see the results of the Language("Ruby","3");
statement assigned to the ruby
reference are off. Because the constructor is called as a plain function, its return value is undefined
due to a lack of an explicit return
statement, therefore the ruby
reference is undefined
and not an object instance like the previous calls with new
. And because the ruby
reference is itself undefined
, the expected properties and methods of a Language
instance are also output as undefined
, including the object's constructor and prototype.
Finally, the last statements in listing 5-4 output the Language.KIND
static property which doesn't require the new
keyword, it's simply a matter of referencing Language
with the static property directly, like it's done to access static properties in built-in JavaScript data types. In addition, notice the KIND
static property isn't available through Language
instances (e.g. python
, javascript
), since it's part of the data type/constructor itself.
Now that you understand how constructors work with custom JavaScript data types, we can take a look at constructor inheritance using multiple custom JavaScript data types.
Prototype-based programming constructors and inheritance, the early years
The prototype chains you've seen up to this point are very simple, with either a built-in JavaScript data type prototype (e.g. Array
, Function
, String
) or a single custom JavaScript data type prototype (e.g. Language
in listing 5-4), followed by the prototype chain Object - null
. Now let's take a look at a more elaborate prototype chain case involving two constructors and inheritance, where one constructor inherits its behavior from another constructor, similar to how classes inherit their behavior from one another in object-orientated programming languages.
Listing 5-5 illustrates how to use multiple constructors with one inheriting behavior from the other, to create a prototype chain that spans multiple custom JavaScript data types.
Listing 5-5. Constructor functions with inheritance and the prototype chain
// Constructor function var Letter = function(value) { "use strict"; // prevents access to 'this' if not local this.value = value; this.iam = function() { return `I am the ${this.constructor.name} ${this.value}`; }; this.alphabet = function() { return `${this.value} is letter No.${Letter.ALPHABET.indexOf(this.value)+1} in the alphabet`; }; }; // Static property, added to constructor Letter.ALPHABET = "abcdefghijklmnopqrstuvwxyz"; let test = new Letter("a"); console.log(test.iam()); console.log(test.alphabet()); console.log("Object.getPrototypeOf(test): %s", Object.getPrototypeOf(test)); console.log("Object.getPrototypeOf(Object.getPrototypeOf(test)): %s", Object.getPrototypeOf(Object.getPrototypeOf(test))); console.log("Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(test))): %s", Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(test)))); console.log("Object.getPrototypeOf(test.constructor): %s", Object.getPrototypeOf(test.constructor)); console.log("Object.getPrototypeOf(Object.getPrototypeOf(test.constructor)): %s", Object.getPrototypeOf(Object.getPrototypeOf(test.constructor))); console.log("Object.getPrototypeOf(Object.getPrototypeOf(test.constructor))): %s", Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(test.constructor)))); // Constructor function var Vowel = function(value) { "use strict"; // prevents access to 'this' if not local if (["a","e","i","o","u"].indexOf(value) === -1) throw new SyntaxError("Invalid vowel"); // Call parent constructor. Letter.call(this,value); }; // Works, but Vowel.prototype needs adjustments to show upstream Letter.prototype let test2 = new Vowel("e"); console.log(test2.iam()); console.log(test2.alphabet()); // Won't work as expected until Vowel.prototype is adjusted console.log("Object.getPrototypeOf(test2): %s", Object.getPrototypeOf(test2)); console.log("Object.getPrototypeOf(Object.getPrototypeOf(test2)): %s", Object.getPrototypeOf(Object.getPrototypeOf(test2))); console.log("Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(test2))): %s", Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(test2)))); console.log("Object.getPrototypeOf(test2.constructor): %s", Object.getPrototypeOf(test2.constructor)); console.log("Object.getPrototypeOf(Object.getPrototypeOf(test2.constructor)): %s", Object.getPrototypeOf(Object.getPrototypeOf(test2.constructor))); console.log("Object.getPrototypeOf(Object.getPrototypeOf(test2.constructor))): %s", Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(test2.constructor)))); // Assign the Vowel.prototype an object instance of Letter.prototype Vowel.prototype = Object.create(Letter.prototype); // Reassigns the correct constructor to Vowel.prototype since its a copy of Letter.prototype Vowel.prototype.constructor = Vowel; let test3 = new Vowel("i"); console.log(test3.iam()); console.log(test3.alphabet()); console.log("Object.getPrototypeOf(test3): %s", Object.getPrototypeOf(test3)); console.log("Object.getPrototypeOf(Object.getPrototypeOf(test3)): %s", Object.getPrototypeOf(Object.getPrototypeOf(test3))); console.log("Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(test3))): %s", Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(test3)))); console.log("Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(test3)))): %s", Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(test3))))); console.log("Object.getPrototypeOf(test3.constructor): %s", Object.getPrototypeOf(test3.constructor)); console.log("Object.getPrototypeOf(Object.getPrototypeOf(test3.constructor)): %s", Object.getPrototypeOf(Object.getPrototypeOf(test3.constructor))); console.log("Object.getPrototypeOf(Object.getPrototypeOf(test3.constructor))): %s", Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(test3.constructor)))); // Get Letter static property 'ALPHABET' console.log("Letter.ALPHABET: %s", Letter.ALPHABET);
The first statement in listing 5-5 is a constructor function that's designed to build a custom object data type named Letter
with the value
property and iam()
and alphabet()
methods. The only novelties of the Letter
constructor function vs. the Language
constructor from listing 5-4 are: the iam()
method uses the this.constructor.name
reference to output the name of the constructor used to build the object -- the reason why it's used will be clearer in a moment; and the alphabet()
method uses the Letter.ALPHABET
static property in its logic. Next, you can see an instance of the Letter
object is created with the letter "a"
, followed by log statements showing the output of the iam()
and alphabet()
methods, as well as the object's prototype chain and the object's constructor prototype chain.
The second constructor function in listing 5-5 Vowel
also accepts an input value
like the Letter
constructor, however, notice the constructor function logic lacks properties and methods. The first step in the Vowel
constructor is to verify the input value
is a vowel, if it isn't the function immediately throws an error. If the input value
is a vowel, then a call is made with Letter.call(this, value)
-- available thanks to the function's prototype chain that has access to Function.prototype.call()
-- which calls the Letter
function, where this
is the execution context (i.e. for Vowel
) passed to the Letter
function so it works without the new
keyword, plus value
is the expected input for the Letter
constructor function.
At this juncture, the Vowel
constructor is equipped to construct objects using the Letter
constructor, notice the test2
object is capable of calling the iam()
and alphabet()
methods that are part of the Letter
data type, as well as changing the output of the iam()
method to I am the Vowel e
, which uses the this.constructor.name
statement depending on the object instance type (i.e. Letter
or Vowel
). However, Vowel
objects are unaware the Letter
constructor forms part of the prototype chain. This can be confirmed in the log statements that show the Vowel
object created with the letter "e"
, output the object's prototype chain as Vowel - Object - null
. The purpose of next the two lines in listing 5-5 is precisely to update the Vowel
constructor's prototype, so all Vowel
objects become aware of the Letter
constructor.
The Vowel.prototype = Object.create(Letter.prototype);
statement works similarly to the prototype
statements presented in earlier examples, except it assigns all the object properties in one step, it says: create an object with all the prototype properties of the Letter
data type and assign them to the prototype property of the Vowel
data type. Next, because this one step assignment of the prototype
property also includes a constructor function, the second Vowel.prototype.constructor = Vowel;
statement ensures the Vowel
data type is reassigned the Vowel
constructor.
Next, an instance of Vowel
is created with the letter "i"
, followed by log statements showing the output of the iam()
and alphabet()
methods, as well as the object's prototype chain and the object's constructor prototype chain. In this case, notice the prototype chain for the test3
object is four levels deep: Vowel { constructor: [Function: Vowel] }- Letter - Object - null
, illustrating how it's possible to create prototype chains with multiple custom data types. Finally, the Letter.ALPHABET
static property is output, showing you don't need to use the new
keyword to output static properties.
To new
or not to new
The new
keyword is used to invoke JavaScript functions and make them behave like constructors to create objects. This essentially means any JavaScript function can work as a data type to produce objects when called with the new
keyword. However, a distinguishing characteristic of calling a function with the new
keyword is it always creates and returns an object instance referenced with the this
keyword, using the function as a boilerplate for a data type.
A source of confusion that can arise with the new
keyword, is some functions support being called without the new
keyword -- in which case they act as plain functions -- as well as with the new
keyword, in which case they act as constructor functions. When to use the new
keyword or not, is largely dependant on what you expect a function to do. Do you expect a function to return a full-fledged object instance, where the function operates as a data type to produce object instances ? Use new
. Or do you expect a function to run some business logic and return a value -- a primitive or inclusively object -- that's unrelated to deriving a data type from the function ? Don't use new
.
Listing 5-6 illustrates a function that can be called both as a plain function -- without the new
keyword -- and as a constructor function with the new
keyword. In addition, the same constructor function also declares a static method to leverage without creating an object instance.
Listing 5-6. Function that works as a constructor function and plain function
var Square = function(number) { "use strict"; if (this && this.constructor.name == "Square") { this.number = number; this.result = number**2; } else { return number**2; } } // Static method, added to constructor Square.calculate = function(number) { return number**2 } var twoPlain = Square(2); console.log(twoPlain) console.log("twoPlain type: %s", typeof twoPlain); var twoObject = new Square(2); console.log(twoObject); console.log("twoObject type: %s", typeof twoObject); console.log("Object.getPrototypeOf(twoObject): %s", Object.getPrototypeOf(twoObject)); console.log("Object.getPrototypeOf(Object.getPrototypeOf(twoObject)): %s", Object.getPrototypeOf(Object.getPrototypeOf(twoObject))); console.log("Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(twoObject))): %s", Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(twoObject)))); var twoStatic = Square.calculate(2); console.log(twoStatic) console.log("twoStatic type: %s", typeof twoStatic);
The Square
constructor/plain function in listing 5-6 takes a number
argument and produces its square value. To allow the function to use "use strict"
and make it work as either a constructor function or plain function, there's a check on the this
reference and its constructor.name
property. When a call is made with new
, the function gets access to an object created by the constructor through the this
reference and enters the this.number
and this.result
assignment logic and returns the this
reference by default. When a call is made without new
, the function won't have access to any this
reference -- thanks to "use strict"
, otherwise it would have access to the external this
reference, albeit the external this
reference would fail the this.constructor.name
check since it wasn't constructed by Square
-- entering the explicit return
statement the calculates the square of the number
argument. In addition, Square
also declares the calculate
property as a static method, to also calculate the square value of a given number without needing to create an object instance.
You can see both calls to Square(2)
and new Square(2)
work, but produce different outcomes. The Square(2)
call produces a value of 2
as a number
primitive. The new Square(2)
call produces a Square
object with the two properties, number
with the argument value and result
with the square of number
, also notice the resulting Square
object has access to a prototype chain with Square - Object - null
. Finally, the call Square.calculate(2)
illustrates how a call is made directly to a static method, without the new
keyword to create an object instance.
This behavior shown in listing 5-6 is the same one exhibited by built-in object data types that have equivalent primitive data types. For example, constructors for object data types like String
and Number
produce primitive values when called without new
and produce full-fledged object instances of the data type when called with new
. Both produce different outcomes and it's the reason why the new
keyword is discouraged with these type of data type constructors, since new
creates objects that by their nature are never the same and difficult to compare.
Listing 5-7 illustrates the String
constructor used without the new
keyword -- as it's generally recommended -- and with new
keyword -- which is generally discouraged. as well as a String
static method that doesn't require the new
keyword.
Listing 5-7. String
constructor called without new
and with new
, including String
static method without new
let lang = String("JavaScript"); let language = "JavaScript"; let langStatic = String.fromCharCode(74, 97, 118, 97, 83, 99, 114, 105, 112, 116); console.log("lang value: %s", lang); console.log("language value: %s", language); console.log("langStatic value: %s", langStatic); console.log("lang type: %s", typeof lang); console.log("language type: %s", typeof language); console.log("langStatic type: %s", typeof langStatic); if (lang == language) { console.log("lang == language"); } if (lang === language) { console.log("lang === language"); } if (language == langStatic) { console.log("language == langStatic"); } if (language === langStatic) { console.log("language === langStatic"); } let langNew = new String("JavaScript"); let languageNew = new String("JavaScript"); console.log("langNew value: %s", langNew); console.log("languageNew value: %s", languageNew); console.log("langNew type: %s", typeof langNew); console.log("langNew type: %s", typeof langNew); if (langNew == languageNew) { console.log("langNew == languageNew"); } else { console.log("langNew != languageNew"); } if (langNew === languageNew) { console.log("langNew === languageNew"); } else { console.log("langNew !== languageNew"); }
Listing 5-7 starts by creating the lang
reference with the String
constructor String("JavaScript")
and the language
reference with the literal value "JavaScript"
, both values are string
primitives and they're both equivalent. Next, you can see the langStatic
reference is assigned a string value with the String
static method String.fromCharCode()
which also doesn't require the new
keyword and that its value is also a string
primitive equivalent to the lang
and language
references. Finally, notice the creation of the langNew
and languageNew
references with a value of new String("JavaScript")
. In this last case, both values are String
objects on top of which both values fail to be equivalent by value or type since they're full-fledged objects.
An interesting behavior of primitive data types produced by built-in object data type constructors or through literal syntax -- like the strings created in in listing 5-7 -- is that even though the underlying result is a primitive, it's a primitive with access to a data type constructor and prototype chain, as if it were an object created with new
, when it isn't. This is the reason why primitives have access to their equivalent full-fledged JavaScript object data types, since primitives have a constructor and prototype chain.
Listing 5-8 illustrates primitive and literal definitions with access to a constructor and prototype chain.
Listing 5-8. Primitives and literals with constructors & prototype chains
let lang = String("JavaScript"); console.log("lang value: %s", lang); console.log("lang type: %s", typeof lang); console.log("lang.constructor.name : %s", lang.constructor.name); console.log("Object.getPrototypeOf(lang) : ", Object.getPrototypeOf(lang)); console.log("Object.getPrototypeOf(Object.getPrototypeOf(lang)) : %s", Object.getPrototypeOf(Object.getPrototypeOf(lang))); console.log("Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(lang))) : %s", Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(lang)))); let language = "JavaScript"; console.log("language value: %s", language); console.log("language type: %s", typeof language); console.log("language.constructor.name : %s", language.constructor.name); console.log("Object.getPrototypeOf(language) : ", Object.getPrototypeOf(language)); console.log("Object.getPrototypeOf(Object.getPrototypeOf(language)) : %s", Object.getPrototypeOf(Object.getPrototypeOf(language))); console.log("Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(language))) : %s", Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(language)))); let languages = ["JavaScript","Python"]; console.log("languages value: %s", languages); console.log("languages type: %s", typeof languages); console.log("languages.constructor.name : %s", languages.constructor.name); console.log("Object.getPrototypeOf(languages) : ", Object.getPrototypeOf(languages)); console.log("Object.getPrototypeOf(Object.getPrototypeOf(languages)) : %s", Object.getPrototypeOf(Object.getPrototypeOf(languages))); console.log("Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(languages))) : %s", Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(languages))));
The first definition in listing 5-8 is a value created with the String()
data type constructor as a plain function, where the log statements confirm it's a string
primitive that has the String
data type constructor, as well as a prototype chain. The second definition in listing 5-8 is declared as a literal "JavaScript"
value, notice the log statements also confirm it's a string
primitive that has the String
data type constructor, as well as a prototype chain. Finally, the third definition is listing 5-8 is declared as literal array, notice the log statements confirm it's an Array
object that has the Array
data type constructor, as well as a prototype chain.
You can conclude from the examples in listing 5-8, that for data types to have access to a constructor or prototype chain, you don't necessarily need to create them it with the new
keyword. The new
keyword is just necessary to create and return full-fledged object instances, prototyped from the function it calls.
Prototype-based getters and setters with get
and set
keywords & property descriptors, the early years
Encapsulation is one of the primary features in OOP (Object Orientated Programming) and consists of restricting access to object properties so they're administered through a dedicated set of methods. In OOP parlance, objects are said to have getters and setters to achieve orderly access to their properties.
In JavaScript, this means a getter method is used to return the value of an object property with business logic or formatting applied to it vs. returning a property's raw value. While a setter method is used to modify and set a property's raw value with business logic or formatting applied to it vs. setting the value of an object property with its raw input.
Getters and setters can be defined through the Object
property descriptors get
and set
, as well as the get
and set
keywords directly in an object literal definition.
Listing 5-9 illustrates how to use both techniques to set an object's getter and setter methods.
Listing 5-9. Getters and setters for objects
var literalLanguage = { name : "Python", version: "3.10", hello: function() { return `Hello from ${this.name}`}, get language() { return `${this.name} ${this.version}`; }, set language(value) { [this.name, this.version] = value.split(" "); }, }; // Verify property value access console.log(literalLanguage.name); console.log(literalLanguage.version); console.log(literalLanguage.hello()); // Access language that uses get console.log(literalLanguage.language); // Update language with setter literalLanguage.language = "JavaScript 2022"; // Verify property values // Access updated language that uses get console.log(literalLanguage.language); // language set updated inividual object properties console.log(literalLanguage.name); console.log(literalLanguage.version); console.log(literalLanguage.hello()); var Language = function(name,version) { this.name = name; this.version = version; this.hello = function() { return `Hello from ${this.name}`; } }; var instanceLanguage = new Language("Python","3.10"); Object.defineProperties(Language.prototype, { language: { get: function() { return `${this.name} ${this.version}`; } ,set: function(value) { [this.name, this.version] = value.split(" "); } } }); // Verify property value access console.log(instanceLanguage.name); console.log(instanceLanguage.version); console.log(instanceLanguage.hello()); // Access language that uses get console.log(instanceLanguage.language); // Update language with setter instanceLanguage.language = "JavaScript 2022"; // Verify property values // Access updated language that uses get console.log(instanceLanguage.language); // language set updated inividual object properties console.log(instanceLanguage.name); console.log(instanceLanguage.version); console.log(instanceLanguage.hello());
Listing 5-9 starts by declaring a literal object, but notice the object properties prefixed with the get
and set
keywords. The get language()
statement indicates that when the language
property on the object is accessed (e.g. literalLanguage.language
) it call this getter method. The set language(value)
statement indicates that when a value is set on the language
property (e.g. literalLanguage.language = "JavaScript"
) it call this setter method. In the case of the get
statement, the logic consists of returning a composite string made up of an object's name
and version
properties, whereas in the case of the set
statement, the logic consists of taking the value
input and using it to reassign values to an object's name
and version
properties.
As you can see in listing 5-9, the literalLanguage
object makes various calls to its properties, including getting the language
value -- supported by get
-- and setting the language
-- supported by set
. In each case, you can see how the getter and setter methods are used to retrieve and define object values, but more importantly, how the data remains encapsulated in the object.
The Language
constructor function in listing 5-9 is like the literal object above it, but unlike the literalLanguage
literal object that directly declares a getter with the get
keyword and a setter with the set
keyword, a constructor function cannot use the same syntax. For this reason, you can see that after the Language
constructor function is declared and the object instanceLanguage
is created, the Object.defineProperties()
method is used to add a getter and setter to the object's language
property.
The Object.defineProperties()
method leveraged in listing 5-9 is the same one used in listing 4-11-A and described in the Object
object property descriptors section. If you look closely, the first argument to Object.defineProperties()
is Language.prototype
, which indicates to add a property on the Language
's prototype
, meaning that all Language
object instances will get said property. Next, is the language
property statement which defines get
and set
property descriptors with getter and setter method logic like the one used in the literalLanguage
literal object from the beginning of the listing. Finally, the same series of operations are performed on the instanceLanguage
object instance, illustrating how it leverages the getter and setter methods added with Object.defineProperties()
on custom data types built with constructor functions.
Do JavaScript objects have other encapsulation/accessibility constructs besides getters and setters ?
In most OOP languages, getter and setter methods are closely used in conjunction with private properties or protected methods to restrict direct access to the properties themselves. In JavaScript there's no such concept, however, JavaScript uses an ad hoc approach with underscore _
syntax to indicate something is not intended to be accessed directly.
As a mere convention, an underscore prefix _
(e.g. _dontaccess
) is used to indicate a reference is private, while a double underscore prefix & suffix (e.g. __dontcall__
) is used by browser makers to also indicate something is private. Of course, this doesn't preclude someone from accessing either of these constructs directly, it's simply a matter of a distinctive enough syntax to make it obvious something isn't meant to be called directly.
If you're looking for more formal support of private properties or protected methods in JavaScript, one option is to use TypeScript. TypeScript does support constructs like the private
and protected
keywords, which in combination with getters and setters provides a similar feel to encapsulation/accessibility behaviors present in other OOP languages (e.g. Java, C#).
Besides underscore _
& __
syntax, JavaScript also supports encapsulation/accessibility of objects through Object
property descriptors, which is how getters and setters are supported, including the ability to change a property value, delete a property and contemplate a property for enumeration. In addition, it's also possible to freeze or seal JavaScript objects to limit what actions can be made on them.
JavaScript OOP keywords: class
, constructor
, extends
, super
, get
, set
and static
, the modern years
If you look closely again at the examples since the beginning of this chapter, you'll realize that with the exception of the subtle prototype-based programming behavior, JavaScript isn't too far away from most OOP behaviors. For example, JavaScript data types are very much like OOP classes, just as constructor functions are pretty similar to OOP constructors. In a similar way, listing 5-5 illustrates OOP functionalities like static properties, inheritance between objects, as well as the ability to call parent constructors like it's done in OOP inheritance. And let's not forget, data types also make use of the this
keyword methods to reference instances, and can also implement getter/setter methods with the get
and set
keywords like other OOP languages.
However, for all these OOP syntax similarities JavaScript offers, they lack one thing: widely used OOP syntax as it's used in other programming languages. Someone with a background in another OOP language (e.g. Java, C#, C++) would be at a loss trying to interpret most of the OOP behavior written in the past sections. Therefore, starting with ES6 (ES2015) JavaScript aligned itself more closely to mainstream OOP syntax used in other languages to make JavaScript object-orientated and prototype-based programming more intuitve.
The class
and constructor
keywords: JavaScript classes
The class
and constructor
keywords are intended to make a clearer delineation between what constitutes a data type and its constructor function. Recall, in previous examples a function served as both a constructor and data type definition, while relying on a title case convention to determine if a function is a constructor. With the aid of the class
keyword it becomes clearer what's referring to the blueprint of a data type, whereas the constructor
keyword makes it clear a function is dedicated to build objects of the class it's associated with. Listing 5-10 illustrates how to use the class
and constructor
keywords.
Listing 5-10. Classes with class
and constructor
keywords
class Language { constructor(name,version) { this.name = name; this.version = version; } hello() { return `Hello from ${this.name}`; } } // Create object instances from class let javascript = new Language("JavaScript","2022"); let python = new Language("Python","3.10"); // Verify property value access console.log(javascript.name); console.log(javascript.version); console.log(javascript.hello()); console.log(python.name); console.log(python.version); console.log(python.hello()); // Properties can be added to an object instances with class python.typed = "Dynamically"; console.log(python.typed); // But the javscript instance won't have a 'typed' property console.log(javascript.typed); // undefined // The 'prototype' is also accesible with class Language.prototype.typed = "Dynamically"; // Now the javascript instance has a 'typed' property, because it was added to its prototype console.log(javascript.typed);
First off, you can compare the examples in listing 5-10 with those in listing 5-4, because they both achieve the same end result but with different syntax.
The first statement in listing 5-10 relies on the class
keyword to create a class named Languge
. Next, inside the Language
class statement are two methods: constructor
and hello
. If it wasn't obvious by its name, the constructor
method is designed to be called every time an object instance of a class
is created (i.e. with the new
keyword), a concept which is almost universal across all OOP languages. In this case, the constructor
method accepts two arguments which means all Language
object instances must be created with two parameters, in addition, the constructor
uses the two arguments to create the name
and version
object properties relying on the this
keyword to reference the object instance. Finally, the hello
class method returns a text message accompanied by the current value of an object's name
property.
Next, two instances of the Language
class are created with the new
keyword and the statements that follow output the instance's properties and call its hello
method, a process which is identical to the sequence presented in listing 5-4 but which relies on a constructor function and a function assigned to a property.
So what's the difference between creating JavaScript object instances like it's done in listing 5-10 and listing 5-4 ? Functionally none, the only difference is the syntax in listing 5-10 is much more obvious, particularlly for those with OOP experience in other languages. To further confirm both approaches are functionally equivalent, notice the second part of listing 5-10 illustrates how it's possible to add a property to an individual object instance, as well as alter the behavior of a class through its prototype
, just like it's done in listing 5-2.
The extends
and super
keywords: JavaScript class inheritance
Inheritance is one of the major features of OOP since it allows classes to retain behaviors from other classes, a process which favors code reusability and the creation of object hierarchies. For example, with inheritance it's possible to have a parent class (e.g.Builiding
) and reuse it to create more granular classes (e.g. ApartmentComplex
, Hospital
, Firehouse
) without the need to reimplement the logic in the parent class.
Although early JavaScript syntax supports object inheritance -- as illustrated in listing 5-5 -- its implementation is not very obvious, especially if you compare it to other OOP languages which have dedicated keywords for inheritance scenarios. The extends
and super
keywords are intended to simplify JavaScript class inheritance. Listing 5-11 illustrates the use of the extends
and super
keywords in conjunction with the class
and constructor
keywords presented in the previous section.
Listing 5-11. JavaScript class inheritance with extends
and super
class Letter { constructor(value) { this.value = value; } iam() { return `I am the ${this.constructor.name} ${this.value}`; } alphabet() { return `${this.value} is letter No.${Letter.ALPHABET.indexOf(this.value)+1} in the alphabet`; } static ALPHABET = "abcdefghijklmnopqrstuvwxyz"; } let test = new Letter("a"); console.log(test.iam()); console.log(test.alphabet()); class Vowel extends Letter { constructor(value) { super(value); if (["a","e","i","o","u"].indexOf(value) === -1) throw new SyntaxError("Invalid vowel"); } } let test2 = new Vowel("i"); console.log(test2.iam()); console.log(test2.alphabet()); let test3 = new Vowel("d"); // Raises syntax error in constructor
First off, you can compare the examples in listing 5-11 with those in listing 5-5, because they both achieve the same end result but with different syntax.
The first declaration in listing 5-11 defines the Letter
class that makes use of the class
and constructor
keywords, in addition to declaring the iam()
and alphabet()
methods. Next, an instance of the Letter
class is created and calls are made to its various methods. Up to this point, it's a standard class and object creation sequence.
The second declaration in listing 5-11 defines the Vowel
class which makes use of the extends
keyword with the Letter
class. This syntax allows the Vowel
class to inherit the same behaviors (i.e. properties and methods) declared in the Letter
class. Next, inside the Vowel
class you can see it only contains a constructor
method which uses the super
keyword and generates an error if a Vowel
object is created with something other than vowel (i.e. a, e, i, o, u). So why doesn't the Vowel
class have any properties and methods ? It could, but in this case it inherits the value
property and iam()
and alphabet()
methods from the Letter
class.
When a Vowel
object instance is created with var test2 = new Vowel("i");
, the Vowel
constructor
method is called and the super(value)
tells JavaScript to call the parent class's constructor (i.e. Letter
) which creates the value
property and gives it access to the iam()
and alphabet()
methods. Next, you can see how it's possible to call the iam()
and alphabet()
methods on a Vowel
object instance. Finally, the last line in listing 5-11 attempts to create a Vowel
object instance with the d
value, but because the constructor
raises an error in case the input is not a vowel the object instance creation fails.
The get
& set
keywords: JavaScript class getters and setters
The get
and set
keywords offer greater versatilty to define getters and setters when used in the context of class
definitions. Remember back in listing 5-9 how get
and set
statements were limited to literal objects or required using either the Object.defineProperty()
or Object.defineProperties()
methods ? With the introduction of classes, get
and set
statements can be part of a class just like they're used in other OOP languages. Listing 5-12 illustrates the use of getters and setters in JavaScript classes.
Listing 5-12. Getters and setters in classes
class Language { constructor(name,version) { this._name = name; this._version = version; } get name() { return this._name; } set name(value) { this._name = value; } get version() { return this._version; } set version(value) { this._version = value; } hello() { return `Hello from ${this.name}`; } } // Create object instances from class let javascript = new Language("JavaScript","2022"); // Verify property value access through getters console.log(javascript.name); console.log(javascript.version); console.log(javascript.hello()); // Reassign property values through setters javascript.name = "ECMAScript"; javascript.version = "ES2022"; // Verify property value updates through getters console.log(javascript.name); console.log(javascript.version); console.log(javascript.hello());
The Language
constructor
in listing 5-12 starts by creating the _name
and _version
object properties -- notice the leading underscore in both properties, which as mentioned previously is a common convention to name private JavaScript properties. Next, a couple of getters and setters are declared for the name
and version
properties, both of which leverage the _name
and _version
properties, respectively.
Next, a Languge
instance is created and assigned to the javascript
reference. Immediatly after, the instance's name
and version
properties are accessed, both of which are supported through the class's getter methods. Next, the instance's name
and version
properties are reassigned -- both of which are supported through the class's setter methods -- and later output to confirm the reassignment setter logic.
The static
keyword: JavaScript class static properties
Finally, another functionality available in JavaScript classes is the static
keyword. Remember back in listing 5-4, listing 5-5 and listing 5-6 you learned about object static properties & methods ? Static properties and methods are those that don't change between object instances and can be accessed without creating an object instance with new
. With support for the static
keyword, JavaScript classes support static properties and methods, as illustrated in listing 5-13.
Listing 5-13. Static properties in classes
class Language { constructor(name,version) { this.name = name; this.version = version; } hello() { return `Hello from ${this.name}`; } static KIND = "HighLevel"; } // Get Language static property 'KIND' console.log("Language.KIND: %s", Language.KIND); // Create object instances with new let javascript = new Language("JavaScript","2022"); let python = new Language("Python","3.10"); // Static properties aren't available in object instances console.log("javascript.KIND: %s", javascript && javascript.KIND ? javascript.KIND: undefined); console.log("python.KIND: %s", python && python.KIND ? python.KIND: undefined); class Square { constructor(number, result) { this.number = number; this.result = result; } static calculate = function(number) { return number**2 } } // Call Square static emthod calculate console.log("Square.calculate(2): %s", Square.calculate(2)); // Create object instance with new var twoObject = new Square(2); // Static methods aren't available in object instances console.log("twoObject.calculate(2): %s", twoObject && twoObject.calculate ? twoObject.calculate(2): undefined);
The Language
class in listing 5-13 contains the static
KIND
static property, identical to the static property declared in listing 5-4 added as a property to the constructor outside its main body. The use of the static
keyword like it's used in other OOP languages, makes it much more obvious to detect when a JavaScript property is intended to be the same across object instances. More importantly, notice the static
property behavior in a class, is the same as when declaring a static property in a constructor function, a static
property is accessed without the new
keyword and it can't be accessed from object instances.
The Square
class in listing 5-13 also uses the static
keyword to define the calculate
static method, identical to the static method declared in listing 5-6 added as a method to the constructor outside its main body. In this case, you can also see the use of the static
makes it more obvious to detect when a JavaScript method is intended to be the same across object instances. In addition, you can also confirm static methods are accesible without the new
keyword and they can't be called from object instances.