Hamburger Icon
  • Labs icon Lab
  • Core Tech
Labs

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.

Labs

Path Info

Level
Clock icon Intermediate
Duration
Clock icon 2h 13m
Published
Clock icon Jul 01, 2024

Contact sales

By filling out this form and clicking submit, you acknowledge our privacy policy.

Table of Contents

  1. 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.
  2. 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 method makeSound and the property feathers.) 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 call mamaWolf.makeSound() or papaWolf.makeSound(), you would be calling the same function — that of Wolf.makeSound().

  3. 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's makeSound method is said to shadow (override) the makeSound from the Wolf prototype. You can undo this behavior using the delete 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 and join:

    1. .map(wolf => wolf.name) returns an array where each element is replaced by its name property.
    2. .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 the Wolf prototype had no effect. This is because all three instances have a name property already, so Wolf.name is shadowed in all three cases.

    This is true regardless of the fact that Wolf.name comes into existence after name 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 not undefined: After deletion, babyWolf.name no longer shadows Wolf.name, so accessing it leads to the prototype member being returned.

  4. Challenge

    Step 4: Prototype Hierarchy

    Earlier, you wrote Object.create calls to make Wolf instances. If you had inspected any instance with Object.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 instantiating papaWolf, 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 or papaWolf.covering — there are two possibilities: The instance either has or doesn't have its own method or property with the name you specified.

    1. If the instance has it, that's what you get back.
    2. 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()).

    Remember the empty array Object.keys will give you right after Object.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 both mamaWolf and papaWolf.

    It can be a good fit for animal taxonomy, though: Wolves and lions are mammals, so you can have two prototypes, Wolf and Lion, that each have Mammal as their prototype. You won't model any silent mammals, so makeSound can move up the prototype chain and be inherited by (i.e., "able to be delegated to from") both types of mammals. Same with covering.

    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 with Mammal as its prototype, that shadowing is required in this case. Notice the limitation of relying on Mammal.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 from Mammal. Otherwise, if you forget to shadow makeSound everywhere necessary, you're leaving the error to alert your users to this mistake at runtime.

  5. Challenge

    Step 5: Extended Prototype Hierarchy

    So far your prototypes have focused on similarities — even with Lion and Wolf overriding makeSound, the point is that you could put lions and wolves in a group and call makeSound 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 from FlightedBird and have FlightedBird inherit from Bird and add a fly 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 using Object.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 access fly on a given birdInstance. Here typeof 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 call canFly on. Nice! Your if (typeof birdInstance.fly === 'function') check can then become a more readable if (birdInstance.canFly()).

    That works, but unfortunately, JavaScript's this has numerous pitfalls. The above canFly implementation breaks if someone invokes it a different way:

    let flyCheckerFunction = mamaSparrow.canFly;
    if (flyCheckerFunction()) mamaSparrow.fly();
    

    In strict mode, JavaScript sets your this to undefined, and your flyCheckerFunction call will throw TypeError: Cannot read properties of undefined (reading 'fly').

    Outside of strict mode, your this is instead globalThis, so your flyCheckerFunction call won't throw an error. Great, right? Actually, it's worse: It will silently return false, and mamaSparrow will not fly even though mamaSparrow.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 your if (birdInstance.canFly()) check becoming if (canFly(birdInstance)):

    function canFly(birdInstance) {
        return typeof birdInstance.fly === 'function';
    }
    
  6. Challenge

    Step 6: Instantiation, Iteration, and Mixins

    In the previous task, you were required to duplicate the makeSound implementation between Bird and Mammal. This can be refactored by expanding your hierarchy by one level, moving makeSound to Animal, and having Bird and Mammal use Animal as a prototype. This will be just like earlier when you moved makeSound up from Wolf to Mammal.

    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 as covering: 'skin' or covering: 'none' or an explicit covering: 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 the Animal prototype, defining it only on Mammal and Bird for now. For Mammal, covering will be the only member you need to add, so Mammal.covering = 'fur'; will suffice.

    But for Bird, you also have layEgg. A handy way to refactor this — keeping the object notation you already had in your let Bird = { ... } statement — is to assign both members at once using Object.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 a zoo array — let zoo = []; — and then your Animal 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 your Object.create calls where you're instantiating can become instantiate() 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 a Wolf rather than a particular wolf like babyWolf. 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 have Birds, 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 and EggLayingMammals, and only include a layEgg member in the latter prototype. The problem with that is, it repeats the layEgg implementation.

    Fortunately, in JavaScript, all functions are passed by reference. So one option is to just create the layEgg function and assign it to both Bird and Platypus 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, and eggsLaidCount, 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 and Platypus prototypes contain everything defined in eggLayingMixin. 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 and eggPrepared) yet for each of the methods (prepareEgg and layEgg), 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 to fly to bats. Since bats are mammals, not birds, the approach of having a FlightedBird prototype mentioned earlier wouldn't make sense.

  7. 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 the class 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):

    1. Methods are defined without the function keyword.
    2. 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 named constructor inside the class. Afterwards, it automatically takes on the name of the class, and you can only call it with the new 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 of Animal, its base class.

    In subclass methods, you can use super (instead of this) to refer explicitly to an inherited method (instead of its overridden counterpart). In particular, if you don't (as in Mammal just above) specify a constructor method, JavaScript will automatically create one for you that simply calls super() to invoke the constructor of its base class. If you do define a constructor, it must call super(), and call it before using this or return.

    Class...mixins?

    Suppose Bird and Platypus are now classes. It's possible to use the object-based mixin you created as-is; instead of assigning to Bird and Platypus, all you need to do is assign to their built-in prototype 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 access mamaSparrow.eggsLaid directly. But it's considered a best practice to generally limit access as much as possible. Suppose you write lots of code accessing eggsLaid 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 add setEggsLaidCount(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 the get keyword instead — i.e., get eggsLaidCount() { ... }. But get 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 the Animal class to define makeSound 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 different makeSound implementations (so far) into one single spot. Then, if you ever needed to change how makeSound 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!

Kevin has 25+ years in full-stack development. Now he's focused on PostgreSQL and JavaScript. He's also used Haxe to create indie games, after a long history in desktop apps and Perl back ends.

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.