JavaScript: Classes and Object-Oriented Programming#

Learn how to use classes in JavaScript and how to write your own.

To download the demo for this lecture, run:

$ dmget wb-classes --demo

Intro#

Why Classes?#

Let’s say we’re building a virtual pet game.

We have objects that store information about pets and functions that operate on those objects.

const ezra = {
  name: 'Ezra',
  species: 'cat',
  hunger: 100,
};

// Feed a pet and reduce their hunger
function feed(pet) {}

// Set the pet's mood to a new value
function setMood(pet, newMood) {}

Potential downsides to this code

Structuring code like this can work, but it’s not ideal for long-term projects. For instance, it’s not immediately apparent that these functions only work on objects with specific properties (name, species, hunger).

It’s also unclear that the objects and functions are designed to be closely related to each other. Even though the object and functions exist in the same file, it’s hard to tell that feed and setMood only work on our particularly-formatted pet objects.

But what if someone created a new pet with different properties?

const ezra = {
  name: 'Ezra',
  species: 'cat',
  hunger: 100,
};

const auden = {
  name: 'Auden',
  type: 'cat',
  hp: 60,
};

We wouldn’t be able to use the same functions with both objects because they have different properties.

Wait, why is this bad?

The ezra and auden objects were probably meant to have the same properties so that the same functions would work on them. There’s variation—for example, hp instead of hunger—making it difficult to reuse code on both objects.

  • Compared to plain objects, classes can store information in a more structured (but less flexible) way.

  • Classes can also “have their own smarts.” They can contain functionality, not just data.

Classes vs. objects#

Feature

Object

Class

Store data

✔️

✔️

Flexible

✔️

Some

Structured

Some

✔️

Comes with functions

✔️

Classes you know#

You’ve already been using classes!

  • Arrays belong to the class Array

  • Strings belong to the class String

  • Numbers belong to the class Number

Goals#

Learn the basics of how to use classes in JavaScript

Then, learn how to write your own class!

One more thing before we start:

  • To give you a preview of what we’re learning next, we’ll be running code in the terminal with node

  • Don’t worry if you’re not sure how it works right away—we’ll go over it in more detail soon!

Classes and Instances#

Classes vs. instances#

  • A class is a type of thing, like Array or String

    • It’s like a blueprint for making instances

    • It’s a plan for house—but not a house itself

  • An instance is an individual occurrence of a class, like [1, 2, 3] or "hello"

    • You can have multiple houses based on one blueprint

    • Multiple instances can exist of the same class

The terms instance and object can be used interchangeably

Classes and instances in JavaScript#

This code illustrates the most important features of classes and instances#
const fruits = new Array('berry', 'apple', 'cherry');
console.log(fruits.length); // 3
fruits.sort();
  • Classes can be used to create instances with the new operator

  • Instances are created with uniform properties—every array has a length and sort method

The most basic class#

Let’s start with the most basic class to talk about how they work.

class Cat {}

class Cat is a class declaration.

Class names are written in UpperCamelCase.

$ node
Welcome to Node.js vXX.X.X.
Type ".help" for more information.
> class Cat{}
undefined

Create an instance by calling the class with the new operator:

const felix = new Cat();
console.log(felix); // Cat {}

felix.name = 'Felix';
console.log(felix); // Cat { name: 'Felix' }

Can have more than one instance of a Cat:

const ezra = new Cat();
console.log(ezra); // Cat {}

Cat() is a constructor function.

Cat objects still work like any other object:

felix.name = 'Felix';
felix.hunger = 100;

for (const attr in felix) {
   console.log(attr);
}

Making Your Own Class#

A class body#

Our Cat class is pretty boring—let’s add to its class body.

A custom constructor#

We want cats to have the same properties every time we create one.

Rather than attaching instance attributes by hand, we can initialize them inside a special function called a constructor.

cat.js#
class Cat {
  constructor(name, hunger = 100) {
    this.name = name;
    this.hunger = hunger;
    this.mood = 'happy';
  }
}

Inside constructor, this refers to the instance being created.

When we create an instance of a class, JavaScript will call the class’s constructor.

const ezra = new Cat('Ezra');
ezra.name; // 'Ezra'
ezra.hunger; // 100

const auden = new Cat('Auden', 60);
auden.name; // 'Auden'
auden.hunger; // 60

Methods#

Instance methods are functions that belong to an instance.

By defining methods on a class, we can give every instance access to the same functions.

cat.js#
class Cat { // ...
  greet() {
    console.log(`Meow, I'm ${this.name} the cat`);
  }
}

Once we define a method, every instance of the class will have access to it.

ezra.greet();
auden.greet();

The power of this#

Inside methods, we can use this to access instance properties.

cat.js#
class Cat { // ...
  graduate() {
    this.name = `Dr. ${this.name}`;
    this.greet();
  }

}
ezra.graduate();
ezra.name; // 'Dr. Ezra'

Instance properties == data and functions!

Prototypes#

Every object (including functions) gets a prototype property.

_images/prototype.png

We can use the prototype to store attributes and methods that should be shared by all instances of a class.

If we add a property to Cat.prototype, then each cat has that property:

const ezra = new Cat('Ezra');
const auden = new Cat('Auden');
Cat.prototype.species = 'Felis catus';

ezra.species;  // 'Felis catus'
auden.species; // 'Felis catus'

Instance properties will override and take priority over prototype properties.

ezra.species;  // 'Felis catus'

ezra.species = 'Party animal';
ezra.species; // 'Party animal'

Understanding this concept is key to designing classes with object-oriented principals.

Classes are “special functions”

In JavaScript, classes are actually just a special kind of function. This doesn’t really affect how we use them, but it’s good to know. In fact, you might see classes written like this in older code:

function Cat(name, hunger) {
  this.name = name;
  this.hunger = hunger;
  this.mood = 'happy';
}

Cat.prototype.greet = function() {
  console.log(`Meow, I'm ${this.name} the cat`);
};

const ezra = new Cat('Ezra', 100);

Object-Oriented Programming#

What’s so great about OO?#

  • Data lives close to its functionality

    • Encapsulation

  • Don’t need to know info a method uses internally

    • Abstraction

  • Easy to make different, interchangeable types of animals

    • Polymorphism

We’ve taken advantage of encapsulation and abstraction but not polymorphism… yet!

Making different types of animals#

Say we want different kinds of animals. Let’s make a more generic Animal class.

class Animal {
  constructor(name, species, hunger = 100) {
    this.name = name;
    this.species = species;
    this.hunger = hunger;
  }

  greet() {
    console.log(`Hey, I'm ${this.name} the ${this.species}`);
  }
}
const rover = new Animal('Rover', 'dog');
const ezra = new Animal('Ezra', 'cat');

rover.greet();
ezra.greet();

Animal is more flexible and generic… but it’s also more boring.

Let’s add more flavor to differentiate different species:

class Animal { // ...
  greet() {
    let greeting;
    if (this.species === 'cat') {
      greeting = 'Meow';
    } else if (this.species === 'dog') {
      greeting = 'Woof';
    } else {
      greeting = 'Hello';
    }

    console.log(`${greeting}, I'm ${this.name} the ${this.species}`);
  }
}

What are the downsides of this approach?

To handle more species, we’d have to add tons of conditions.

There has to be a better way!

Inheritance#

Inheriting from Animal#

We can make our Cat class inherit from Animal with extends.

class Cat extends Animal {}

This is like copying and pasting all the contents of Animal into Cat.

Vocab: Superclass and Subclass#

Superclass

A parent class. Animal is a superclass of Cat

Subclass

A child class. Cat is a subclass of Animal

Subclasses automatically get their parents’ properties and can add their own.

class Cat extends Animal {
  purr() {
    console.log('Purrrrrrr');
  }
}

const ezra = new Cat('Ezra', 'cat');
ezra.greet();  // Hi, I'm Ezra the cat
ezra.purr();   // Purrrrrrr

This still isn’t great though: we have to pass in a species when we create a Cat and the cats no longer say 'Meow'.

Overriding properties#

Subclasses can override their parents’ properties.

Let’s override the greet method so cats say 'Meow':

class Cat extends Animal {
  greet() {
    return `Meow, I'm ${this.name} the ${this.species}`;
  }
}

const ezra = new Cat('Ezra', 'cat');
ezra.hunger;   // 100 (inherited from Animal)
ezra.greet();  // Meow, I'm Ezra the cat

When you ask for an attribute/method on an object, for example ezra.hunger:

  • First, it looks for an attribute called hunger on the instance.

  • Next, it looks for an attribute called hunger on the class’s prototype.

  • Finally, it looks for an attribute called hunger on the superclass, if any.

Reusing code from the superclass#

This greet method is still almost identical to the greet method in Animal.

We can eliminate this repetition by refactoring greet in Animal

inheritance.js#
class Animal { // ...
  greet(greeting = 'Hi') {
    console.log(`${greeting}, I'm ${this.name} the ${this.species}`);
  }
}

...and then use super to call the superclass’s method:

inheritance.js#
class Cat extends Animal { // ...
  greet() {
    super.greet('Meow');
  }
}
const ezra = new Cat('Ezra', 'cat');
ezra.greet();

Overriding the constructor#

We’d also like to not have to pass in a species when creating a Cat instance, because the species will always be 'cat'.

To do that, we can override the parent’s constructor.

When overriding a constructor, you must begin by calling the superclass’s constructor with super().

inheritance.js#
class Cat extends Animal {
  constructor(name, hunger = 100) {
    super(name, 'cat', hunger);
  }
}

Now we no longer need to pass in a species for Cat!

const ezra = new Cat('Ezra');
ezra.greet();

Subclasses can also add their own attributes inside the constructor, after the call to super():

class Cat extends Animal {
  constructor(name, hunger=100) {
    super(name, 'cat', hunger);
    this.breed = 'tabby';
  }
}

const ezra = new Cat('Ezra');
ezra.breed;  // 'tabby'

Recap#

Three benefits of OOP#

Abstraction

Hiding details we don’t need

Encapsulation

Keeping everything “together”

Polymorphism

Different types, same interface

Design considerations#

Polymorphism is the ability to treat different types the same way and without conditionals.

When animals don’t have a uniform way to greet, we have to resort to conditionals:

for (const someAnimal of familyAnimals) {
  if (someAnimal.species === 'dog') {
    someAnimal.bark();
  } else if (someAnimal.species === 'cat') {
    someAnimal.meow();
  } else if (someAnimal.species === 'bird') {
    someAnimal.squawk();
  } // ...etc.
}

This is much better:

for (const someAnimal of familyAnimals) {
  someAnimal.greet();
}

When overriding methods, it’s common for constructors to take different args:

class Animal {
  constructor(name, species, hunger = 100) {
    // ...
  }
}

class Cat extends Animal{
  constructor(name, hunger = 100) {
    // ...
  }
}
const partyAnimal = new Animal('Sanic', 'party animal');
const ezra = new Cat('Ezra');

...but not for other methods:

class Animal {
  greet(greeting) {
    // ...
  }
}

class Cat extends Animal{
  greet() {
    super().greet('Meow')
  }
}
for (const animal of animals) {
  animal.greet(); // uh-oh...
}

This is often solved with default arguments:

class Animal {
  greet(greeting = 'Hi') {
    // ...
  }
}

class Cat extends Animal{
  greet() {
    super().greet('Meow')
  }
}
for (const animal of animals) {
  animal.greet(); // yay!
}

Looking Ahead#

Coming up#

  • Designing class hierarchies

  • More on Node.js and the node command

Further study#