- Lab
- 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.

Path 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
lastAnimalOfItsKind
has two members: the methodmakeSound
and 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
Wolf
prototype 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 Howl
Here,
babyWolf
'smakeSound
method is said to shadow (override) themakeSound
from theWolf
prototype. You can undo this behavior using thedelete
keyword: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.keys
returns 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
map
andjoin
:.map(wolf => wolf.name)
returns an array where each element is replaced by itsname
property..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 Wolf
The second line was the same as the first. In other words, adding a
name
property to theWolf
prototype had no effect. This is because all three instances have aname
property already, soWolf.name
is shadowed in all three cases.This is true regardless of the fact that
Wolf.name
comes into existence aftername
is 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 Wolf
and notundefined
: After deletion,babyWolf.name
no longer shadowsWolf.name
, so accessing it leads to the prototype member being returned. -
Challenge
Step 4: Prototype Hierarchy
Earlier, you wrote
Object.create
calls to makeWolf
instances. If you had inspected any instance withObject.keys
immediately 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.makeSound
orpapaWolf.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.keys
will 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:
babyWolf
can have only one prototype, so it can't delegate to bothmamaWolf
andpapaWolf
.It can be a good fit for animal taxonomy, though: Wolves and lions are mammals, so you can have two prototypes,
Wolf
andLion
, that each haveMammal
as their prototype. You won't model any silent mammals, somakeSound
can 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.makeSound
simply throw an error. This can serve during development to remind you, or any other developer who might create an object withMammal
as 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 shadowmakeSound
everywhere 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
Lion
andWolf
overridingmakeSound
, the point is that you could put lions and wolves in a group and callmakeSound
on 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
fly
method 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
Sparrow
inherit fromFlightedBird
and haveFlightedBird
inherit fromBird
and add afly
method. 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
fly
usingObject.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 accessfly
on a givenbirdInstance
. Heretypeof
can 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
this
keyword will be set to whatever instance we callcanFly
on. Nice! Yourif (typeof birdInstance.fly === 'function')
check can then become a more readableif (birdInstance.canFly())
.That works, but unfortunately, JavaScript's
this
has numerous pitfalls. The abovecanFly
implementation breaks if someone invokes it a different way:let flyCheckerFunction = mamaSparrow.canFly; if (flyCheckerFunction()) mamaSparrow.fly();
In strict mode, JavaScript sets your
this
toundefined
, and yourflyCheckerFunction
call will throwTypeError: Cannot read properties of undefined (reading 'fly')
.Outside of strict mode, your
this
is insteadglobalThis
, so yourflyCheckerFunction
call won't throw an error. Great, right? Actually, it's worse: It will silently returnfalse
, andmamaSparrow
will notfly
even 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
makeSound
implementation betweenBird
andMammal
. This can be refactored by expanding your hierarchy by one level, movingmakeSound
toAnimal
, and havingBird
andMammal
useAnimal
as a prototype. This will be just like earlier when you movedmakeSound
up fromWolf
toMammal
.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
covering
from theAnimal
prototype, defining it only onMammal
andBird
for now. ForMammal
,covering
will 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
Animal
prototype paves the way for you to automatically add all animals to a zoo as they're created. All you need is azoo
array —let zoo = [];
— and then yourAnimal
prototype can have a function like this:instantiate() { let newInstance = Object.create(this); zoo.push(newInstance); return newInstance; },
Since this function does the
Object.create
call for you, all of yourObject.create
calls 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.create
calls 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 aWolf
rather 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
Mammal
s and you haveBird
s, 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
NormalMammals
andEggLayingMammals
, and only include alayEgg
member in the latter prototype. The problem with that is, it repeats thelayEgg
implementation.Fortunately, in JavaScript, all functions are passed by reference. So one option is to just create the
layEgg
function and assign it to bothBird
andPlatypus
prototypes: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
this
you 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
Bird
andPlatypus
prototypes 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 (eggsLaidCount
andeggPrepared
) yet for each of the methods (prepareEgg
andlayEgg
), 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 tofly
to bats. Since bats are mammals, not birds, the approach of having aFlightedBird
prototype 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
class
block is automatically processed in strict mode.Class syntax
As with the
function
keyword, you can use theclass
keyword 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
class
definitions (unlike object definitions):- Methods are defined without the
function
keyword. - 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
instantiate
function 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 namedconstructor
inside the class. Afterwards, it automatically takes on the name of the class, and you can only call it with thenew
keyword 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.create
inside 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
extends
keyword:class Mammal extends Animal { covering = 'fur'; }
Here,
Mammal
is 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 inMammal
just above) specify aconstructor
method, 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 usingthis
orreturn
.Class...mixins?
Suppose
Bird
andPlatypus
are now classes. It's possible to use the object-based mixin you created as-is; instead of assigning toBird
andPlatypus
, all you need to do is assign to their built-inprototype
properties:[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.assign
with 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.eggsLaid
directly. But it's considered a best practice to generally limit access as much as possible. Suppose you write lots of code accessingeggsLaid
directly 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
eggsLaid
into 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 theget
keyword instead — i.e.,get eggsLaidCount() { ... }
. Butget
accessors 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 theAnimal
class to definemakeSound
dynamically: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.log
detail would be abstracted from being repeated across five differentmakeSound
implementations (so far) into one single spot. Then, if you ever needed to change howmakeSound
generally 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
What's a lab?
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.
Provided environment for hands-on practice
We will provide the credentials and environment necessary for you to practice right within your browser.
Guided walkthrough
Follow along with the author’s guided walkthrough and build something new in your provided environment!
Did you know?
On average, you retain 75% more of your learning if you get time for practice.