- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Guided: Using Prototypes and Classes in JavaScript
In this lab, you'll create a virtual zoo where different kinds of animals can be modeled and managed. This will give you hands-on practice creating reusable, extensible, and mixable models of real-world conceptions, discovering first-hand the benefits and limitations of various approaches to encapsulation.
Lab Info
Table of Contents
-
Challenge
Step 1: Introduction
You may have heard that in JavaScript, "everything is an object." While not strictly true, it can certainly seem that way: Arrays and functions are objects, and even numbers and strings can appear to behave like objects thanks to auto-boxing. So objects are quite fundamental to JavaScript, and knowing how they're organized is key to becoming a more advanced JavaScript programmer.
This lab will guide you through the construction of a virtual zoo, showing how prototypes and classes can be used to model various animals and their behaviors. Along the way, you'll discover first-hand the pros and cons of some object-oriented programming strategies.
Some notes:
- You'll do all your coding in the file
main.js. You should keep strict mode enabled by leaving"use strict";as the first line. - Unless otherwise instructed, leave the code from your previous task intact when starting a new task, and write your new code below it.
- If you get stuck on a task, you can consult the
solutions/folder.
- You'll do all your coding in the file
-
Challenge
Step 2: Prototypes and Instances
JavaScript objects are a handy way to group together related information. They're like folders with files in them — files can either be programs that you can run or data that the programs can use. With objects, the files are called members, and more specifically, they're either methods (i.e., functions) or properties (i.e., variables):
let lastAnimalOfItsKind = { makeSound() { // method console.log('Squawk'); }, covering: 'feathers', // property };(Here the object literal
lastAnimalOfItsKindhas two members: the methodmakeSoundand the propertyfeathers.) Back to the folder metaphor, suppose you have a folder representing a template. You can make a copy of that folder, and the new folder will contain copies of each of the files in the original folder. But if you copied it a thousand times, each file within would also be copied a thousand times — even if some files' copies you never intend to modify. Not very efficient.Fortunately, a JavaScript object is a bit more sophisticated than that. You can use it as a prototype to create another object (an instance) based on it, but its methods and properties aren't copied. You'll learn more detail in a later step, but for now, here's one way to use an object literal as a prototype:
let myNewObject = Object.create(MyObjectLiteral);A common naming convention is to give prototypes an initial capital (
MyObjectLiteral) in contrast to instances (myNewObject). At this point, if you were to callmamaWolf.makeSound()orpapaWolf.makeSound(), you would be calling the same function — that ofWolf.makeSound(). -
Challenge
Step 3: Prototype Chains
Both the
Wolfprototype and the instances created from it are objects, which are extensible by default. This means you can add new members to them, regardless of what their prototypes contain:mamaWolf.bareTeeth = () => { console.log('Grr'); };You can also assign properties and methods that already exist on an object's prototype:
babyWolf.makeSound = () => { console.log('Howl (squeakily)'); }; babyWolf.makeSound(); // calls custom makeSound, which logs Howl (squeakily) papaWolf.makeSound(); // calls Wolf.makeSound, which logs HowlHere,
babyWolf'smakeSoundmethod is said to shadow (override) themakeSoundfrom theWolfprototype. You can undo this behavior using thedeletekeyword:delete babyWolf.makeSound; babyWolf.makeSound(); // calls Wolf.makeSound, which logs Howl(If this doesn't quite make sense, you'll see the reason for this behavior in the next step.)
For now, how can you see all the members of an object?
Object.keysreturns an array of them, but notice that it doesn't include members from an object's prototype:Object.keys(mamaWolf) // Array [ "bareTeeth" ] Object.keys(Wolf) // Array [ "makeSound", "covering" ]There's no built-in way of listing members at run time in a way that includes everything from the rest of an object's prototype chain. (If you ever need that functionality, you'll need to traverse the chain with a loop.) If you're not familiar with the array functions
mapandjoin:.map(wolf => wolf.name)returns an array where each element is replaced by itsnameproperty..join(', ')returns a string consisting of all array values concatenated with,between them.
So the output from your three log statements is:
Mama Wolf, Papa Wolf, Baby Wolf Mama Wolf, Papa Wolf, Baby Wolf Mama Wolf, Papa Wolf, Anonymous WolfThe second line was the same as the first. In other words, adding a
nameproperty to theWolfprototype had no effect. This is because all three instances have anameproperty already, soWolf.nameis shadowed in all three cases.This is true regardless of the fact that
Wolf.namecomes into existence afternameis added to each instance. So it's not that later assignments override earlier ones, in this sense. Instead, instance members simply override prototype members.This is the same reason the third line has
Anonymous Wolfand notundefined: After deletion,babyWolf.nameno longer shadowsWolf.name, so accessing it leads to the prototype member being returned. -
Challenge
Step 4: Prototype Hierarchy
Earlier, you wrote
Object.createcalls to makeWolfinstances. If you had inspected any instance withObject.keysimmediately after creation, it would have returned an empty array. Remember that this is because an instance doesn't receive copies of its prototype's members — neither its methods nor its properties.How can
papaWolf.makeSound()work immediately after instantiatingpapaWolf, then? It's because upon instantiation, the prototype simply becomes part of the new instance's prototype chain, used for delegation.What's delegation?
When you try to access an instance member — e.g.
papaWolf.makeSoundorpapaWolf.covering— there are two possibilities: The instance either has or doesn't have its own method or property with the name you specified.- If the instance has it, that's what you get back.
- If the instance doesn't have it, JavaScript attempts delegation. The prototype chain is followed — from the instance's prototype, to its prototype's prototype, etc., — until a prototype does have it, and that's what you get back.
- If JavaScript follows the entire prototype chain without finding it, you get back
undefined, which can throw a runtime error if you assume it's a valid function and try to invoke it (e.g.,papaWolf.findBaby()).
- If JavaScript follows the entire prototype chain without finding it, you get back
Remember the empty array
Object.keyswill give you right afterObject.create— that means that delegation is the default. But as soon as you assign something to an instance member, it now has its own version/copy of that member, and accessing that member is now case #2 above rather than case #1.How can you tell if an object has its own member, or if it's delegating? With
Object.hasOwn:let babyWolf = Object.create(Wolf); Object.hasOwn(babyWolf, 'covering'); // false babyWolf.covering = 'patchy fur'; Object.hasOwn(babyWolf, 'covering'); // true(If you see code in the wild using
hasOwnProperty, note that that approach is deprecated in favor of the above.)Prototype hierarchies
Sometimes, modeling the real world via prototype chains works reasonably well. The prerequisite for this is that the model in question fits neatly into a single category.
Note that this is a poor fit for representing the family tree of your wolf pack:
babyWolfcan have only one prototype, so it can't delegate to bothmamaWolfandpapaWolf.It can be a good fit for animal taxonomy, though: Wolves and lions are mammals, so you can have two prototypes,
WolfandLion, that each haveMammalas their prototype. You won't model any silent mammals, somakeSoundcan move up the prototype chain and be inherited by (i.e., "able to be delegated to from") both types of mammals. Same withcovering.But what sort of sound does a generic mammal make? Hard to say. In terms of modeling it in JavaScript, though, you can have
Mammal.makeSoundsimply throw an error. This can serve during development to remind you, or any other developer who might create an object withMammalas its prototype, that shadowing is required in this case. Notice the limitation of relying onMammal.makeSound's error alone to keep you from shipping a mistakenly silent mammal in production. You would have to make sure that you're attempting — manually or via automated testing — a.makeSound()invocation on every object that inherits fromMammal. Otherwise, if you forget to shadowmakeSoundeverywhere necessary, you're leaving the error to alert your users to this mistake at runtime. -
Challenge
Step 5: Extended Prototype Hierarchy
So far your prototypes have focused on similarities — even with
LionandWolfoverridingmakeSound, the point is that you could put lions and wolves in a group and callmakeSoundon all of them. How can you handle prototypes with different sets of members?Take birds as an example. Some species can fly, some can't, so you can model this by adding a
flymethod only to those that can:let Chicken = Object.create(Bird); let Sparrow = Object.create(Bird); Sparrow.fly = () => { console.log('Flying'); }; let mamaChicken = Object.create(Chicken); let mamaSparrow = Object.create(Sparrow);If you wanted to model several types of flighted birds, you could have
Sparrowinherit fromFlightedBirdand haveFlightedBirdinherit fromBirdand add aflymethod. But you still might run into a case where you have a group of both flighted and flightless birds and you want to see which of them can fly.Earlier it was noted that there's no built-in way to list all the members of an instance. Thankfully, there's no need to implement your own prototype chain crawler and check each level for the presence of
flyusingObject.hasOwn. When you already know the member name, this type of chain traversal is automatic. But you do need to check what you get back when you attempt to accessflyon a givenbirdInstance. Heretypeofcan be useful:[mamaChicken, mamaSparrow].forEach(birdInstance => { if (typeof birdInstance.fly === 'function') { birdInstance.fly(); } else { console.log('Not flying'); } });Are you ready for
this?Suppose you want to abstract the functionality you just saw by creating a method on
Bird:canFly() { return typeof this.fly === 'function'; }Here, the
thiskeyword will be set to whatever instance we callcanFlyon. Nice! Yourif (typeof birdInstance.fly === 'function')check can then become a more readableif (birdInstance.canFly()).That works, but unfortunately, JavaScript's
thishas numerous pitfalls. The abovecanFlyimplementation breaks if someone invokes it a different way:let flyCheckerFunction = mamaSparrow.canFly; if (flyCheckerFunction()) mamaSparrow.fly();In strict mode, JavaScript sets your
thistoundefined, and yourflyCheckerFunctioncall will throwTypeError: Cannot read properties of undefined (reading 'fly').Outside of strict mode, your
thisis insteadglobalThis, so yourflyCheckerFunctioncall won't throw an error. Great, right? Actually, it's worse: It will silently returnfalse, andmamaSparrowwill notflyeven thoughmamaSparrow.canFly().You can't control how someone might invoke your functions, and you may not always remember to invoke them a certain way, so this is a caveat to keep in mind whenever you write code that relies on
this.In practice, if you remember to
"use strict", you may judge the convenience to be worth the risk. If not, a regular, non-member function will suffice as a workaround, if you're OK with yourif (birdInstance.canFly())check becomingif (canFly(birdInstance)):function canFly(birdInstance) { return typeof birdInstance.fly === 'function'; } -
Challenge
Step 6: Instantiation, Iteration, and Mixins
In the previous task, you were required to duplicate the
makeSoundimplementation betweenBirdandMammal. This can be refactored by expanding your hierarchy by one level, movingmakeSoundtoAnimal, and havingBirdandMammaluseAnimalas a prototype. This will be just like earlier when you movedmakeSoundup fromWolftoMammal.What about
covering, though? There's more than one way to approach it. Some animals just have skin — no hair, feathers, scales, etc. You could model it ascovering: 'skin'orcovering: 'none'or an explicitcovering: undefined. It depends what the rest of your code will do with it.For this part of the lab, you can simply omit
coveringfrom theAnimalprototype, defining it only onMammalandBirdfor now. ForMammal,coveringwill be the only member you need to add, soMammal.covering = 'fur';will suffice.But for
Bird, you also havelayEgg. A handy way to refactor this — keeping the object notation you already had in yourlet Bird = { ... }statement — is to assign both members at once usingObject.assign:let Bird = Object.create(Animal); Object.assign(Bird, { covering: 'feathers', layEgg() { console.log("Laying an egg"); }, });Having an
Animalprototype paves the way for you to automatically add all animals to a zoo as they're created. All you need is azooarray —let zoo = [];— and then yourAnimalprototype can have a function like this:instantiate() { let newInstance = Object.create(this); zoo.push(newInstance); return newInstance; },Since this function does the
Object.createcall for you, all of yourObject.createcalls where you're instantiating can becomeinstantiate()calls on the applicable prototype. For example:let mamaChicken = Chicken.instantiate(); // replaces Object.create(Chicken)However, all your
Object.createcalls that serve to create new prototypes in the hierarchy will still the same. You don't want to convert these calls, otherwise it would be like your zoo containing, e.g., the concept of aWolfrather than a particular wolf likebabyWolf. In other words, you only want instances in the zoo, not prototypes.Once your code uses
instantiate()for all instances, you're in a position to loop over the whole zoo. In fact, you can also loop over the abilities an animal may have:zoo.forEach(animalInstance => { ['makeSound', 'fly'].forEach(action => { if (typeof animalInstance[action] === 'function') { animalInstance[action](); } else { console.log(`Can't ${action}`); } }) });Like this, when you add a new animal ability, it's trivial to include its demonstration in this loop. OK, you have
Mammals and you haveBirds, and your birds can lay eggs. What about the platypus, the unusual mammal that lays eggs? How can you model the fact that it lays eggs?With flightless and flighted birds, one solution was to create a prototype for each category. You could likewise create
NormalMammalsandEggLayingMammals, and only include alayEggmember in the latter prototype. The problem with that is, it repeats thelayEggimplementation.Fortunately, in JavaScript, all functions are passed by reference. So one option is to just create the
layEggfunction and assign it to bothBirdandPlatypusprototypes:function layEgg() { console.log("Laying an egg"); } Bird.layEgg = layEgg; Platypus.layEgg = layEgg;But what if your functionality is more complicated? Maybe you have
prepareEgg,eggPrepared,layEgg, andeggsLaidCount, with the methods needing access to the properties. It would be cumbersome to have to assign each of these to every prototype that needs them.In this case — keeping in mind the caveats of
thisyou learned about earlier — using a mixin can be an appropriate technique to share functionality:let eggLayingMixin = { eggsLaidCount: 0, // accessed by layEgg eggPrepared: false, // accessed by both methods prepareEgg() { if (this.eggPrepared) { throw new Error("Egg already prepared"); } this.eggPrepared = true; console.log("Egg prepared"); }, layEgg() { this.eggPrepared = false; this.eggsLaidCount++; console.log("New egg laid. Total: " + this.eggsLaidCount); } }; [Bird, Platypus].forEach( animalPrototype => Object.assign(animalPrototype, eggLayingMixin) );Like this, both the
BirdandPlatypusprototypes contain everything defined ineggLayingMixin. Furthermore, each instance you create from those prototypes (or from prototypes that inherit from either of them, e.g.Chicken) will have its own properties (eggsLaidCountandeggPrepared) yet for each of the methods (prepareEggandlayEgg), only a single copy of the function will exist, being shared among all prototypes and instances. You're not asked to in this lab, but you could also use the mixin technique to extend the ability toflyto bats. Since bats are mammals, not birds, the approach of having aFlightedBirdprototype mentioned earlier wouldn't make sense. -
Challenge
Step 7: Classes and Private Class Fields
Where you've so far used JavaScript's original prototype delegation system, you'll now accomplish the same thing using a more recent technique. A JavaScript class is similar to an object prototype, and creates the same prototype chain mechanism underneath. But it uses slightly different syntax and has different pros and cons — for example, all code inside a
classblock is automatically processed in strict mode.Class syntax
As with the
functionkeyword, you can use theclasskeyword to define (as a statement) or express (as a value) a body enclosed with{}; when used as an expression, it can be named or anonymous:// expression: let XYZ = class SomeClass { ... } let DEF = class { ... } // anonymous // statement: class SomeOtherClass { ... }Within
classdefinitions (unlike object definitions):- Methods are defined without the
functionkeyword. - There's no
,separator between members.
Also, fields are members that will be created for each instance. They can have functions as their assigned values, but that means they're copied to each new instance, instead of shared:
class someClass { // fields a = 1; // will be copied (just like with objects) fieldHoldingAFunction = function() { ... }; // will be copied (oops!) fieldHoldingAFunction = () => { ... }; // will be copied (oops!) // class methods sharedFunction() { ... } }Class constructors
The
instantiatefunction you created earlier was part of a useful pattern, and this pattern is built into JavaScript classes: A constructor is an optional special function that must be namedconstructorinside the class. Afterwards, it automatically takes on the name of the class, and you can only call it with thenewkeyword before it to make an instance of the class:class Animal { constructor() { zoo.push(this); } } let coyote = new Animal();Note that you don't call
Object.createinside the constructor, since that's already done automatically. (It would work, but then JavaScript would be creating an extra object for nothing.)Class inheritance
When you use a class as a prototype for another class, it's called subclassing, and it's as easy as using the
extendskeyword:class Mammal extends Animal { covering = 'fur'; }Here,
Mammalis a subclass ofAnimal, its base class.In subclass methods, you can use
super(instead ofthis) to refer explicitly to an inherited method (instead of its overridden counterpart). In particular, if you don't (as inMammaljust above) specify aconstructormethod, JavaScript will automatically create one for you that simply callssuper()to invoke the constructor of its base class. If you do define a constructor, it must callsuper(), and call it before usingthisorreturn.Class...mixins?
Suppose
BirdandPlatypusare now classes. It's possible to use the object-based mixin you created as-is; instead of assigning toBirdandPlatypus, all you need to do is assign to their built-inprototypeproperties:[Bird, Platypus].forEach(AnimalClass => Object.assign(AnimalClass.prototype, eggLayingMixin));But there's a catch. Some features (such as privacy, which you'll learn about below) can only be used inside class blocks. And while you could use
Object.assignwith an instance of a mixin class, its regular class methods won't be assigned. If you try to get around this using fields that hold functions, those will be copied instead of shared, as explained earlier in this step. And if you try to get around this by manually assigning each function to the prototype, again, features like privacy won't work.So instead of
Object.assign, you can use a class extension function:function eggLayingMixin(Base) { return class extends Base { // ... } }With this approach, your function returns a class that extends the class you pass it:
class PlatypusBase extends Mammal { // previous Platypus definition here } class Platypus extends eggLayingMixin(PlatypusBase) {}The disadvantage here is that you lose the ability to use the
[Bird, Platypus].forEach(...)trick. In other words, each class you use this on must first be defined as a base, then the final class will extend the base using the mixin function. Great, your zoo is class-based. That means now you can leverage private class fields. Previously, all object members and class fields you've written have been public by default. For example, you can accessmamaSparrow.eggsLaiddirectly. But it's considered a best practice to generally limit access as much as possible. Suppose you write lots of code accessingeggsLaiddirectly on instances. Any class refactoring you do might break all of that code. Worse yet, if this is an API you're sharing with others, it's not just your own code you might break.So how can you make
eggsLaidinto a private class field to defend against its misuse? Everywhere it appears, just prepend its name with#:#eggsLaidCount = 0; // ... this.#eggsLaidCount++; // etc.Note:
- The
#syntax only works within class bodies. - Private class fields must be declared explicitly, not added dynamically in a constructor.
- They cannot be deleted.
- They cannot be inherited.
That last point may come as a surprise. In fact, it can be quite an inconvenience, if you wanted subclasses to also make use of a particular private class field while keeping it inaccessible outside the subclass. This "private to a class and any subclasses" visibility — somewhere between public and private — is called protected in other programming languages; unfortunately, JavaScript doesn't have this feature built-in.
One approach to this limitation is to use the convention that, if a variable name begins with
_, treat it as protected — i.e., you and anyone else using your class know not to such a variable outside the class. It's then technically public and can be used within subclassses, while hopefully not accruing any technical debt from misuse.Another approach, if you want to still use
#, is to expose a private class field via a public class member:getEggsLaidCount() { return this.#eggsLaidCount; }Like this, you work around part of the privacy that
#provides. But if you addsetEggsLaidCount(newCount) { this.#eggsLaidCount = newCount; }too, there's no point in using privacy at all.(This particular example of read-only private field exposure is simple enough that you could ditch the
#and use thegetkeyword instead — i.e.,get eggsLaidCount() { ... }. Butgetaccessors can't have parameters, so you couldn't pass a user variable to check whether the user had the authorization to access this data, for example.) While it wasn't required of you for this lab, you should know that constructor functions can take parameters. You could have used this, for example, to refactor theAnimalclass to definemakeSounddynamically:constructor(sound) { this.makeSound = function() { console.log(sound); }; }Then your subclasses could have used it via
super:class Chicken extends Bird { constructor() { super('Cluck'); } }The advantage here is that the
console.logdetail would be abstracted from being repeated across five differentmakeSoundimplementations (so far) into one single spot. Then, if you ever needed to change howmakeSoundgenerally worked — e.g.console.log(`${name} says, "${sound}!"`)— you would only have to change it in that one spot.
Congratulations on completing this lab!
- Methods are defined without the
About the author
Real skill practice before real-world application
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Learn by doing
Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.
Follow your guide
All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.
Turn time into mastery
On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.