Classes in JavaScript

Feb 20, 2022

12 min read

JavaScript Classes: A Complete Guide for Beginners

Classes in JavaScript give you a clean, readable way to create objects and manage inheritance. In this post, we'll break down everything about classes, from the basics to the more advanced features like private fields and static methods.

Creating a Class

There are two ways to create a class in JavaScript: class declarations and class expressions. Think of it like how you can define functions, either as declarations or expressions.

Class Declaration

This is the most common way to define a class:

class User {
  constructor(name, email) {
    this.name = name;   // instance property
    this.email = email;
  }
}

const user1 = new User("Manak", "manak@example.com");
console.log(user1.name);  // "Manak"
console.log(user1.email); // "manak@example.com"

Class Expression

You can also assign a class to a variable, just like a function expression:

// unnamed class expression
const User = class {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }
};

console.log(User.name); // "User"
// named class expression
const User = class UserClass {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }
};

console.log(User.name); // "UserClass"

With named class expressions, the name (UserClass) is only accessible inside the class body itself. Outside, you use the variable name (User).

Hoisting: An Important Difference

Class declarations are hoisted but not initialized. This means you can't use a class before it's declared, unlike function declarations.

const user = new User("Manak"); // ReferenceError: Cannot access 'User' before initialization

class User {
  constructor(name) {
    this.name = name;
  }
}

Class expressions behave the same way. You must define the class before using it.

Body of a Class

The body of a class is everything inside the curly brackets {}. This is where you define constructors, methods, fields, getters, setters, and more.

ℹ️

The body of a class always runs in strict mode. This means you get stricter syntax rules and better error handling automatically, without needing to write "use strict".

Constructor

The constructor is a special method that runs when you create a new instance of the class using new. It's where you set up the initial state of your object.

class Product {
  constructor(name, price) {
    this.name = name;
    this.price = price;
    this.inStock = true;
  }
}

const laptop = new Product("MacBook Pro", 2499);
console.log(laptop.name);    // "MacBook Pro"
console.log(laptop.inStock); // true

Every class can have only one constructor. If you don't write one, JavaScript provides a default:

For a base class (no parent):

constructor() {}

For a derived class (has a parent):

constructor(...args) {
  super(...args);
}

What Happens When You Use new

When you call new on a class, JavaScript goes through these steps:

  1. (Derived class only) The constructor body before super() runs. You can't use this yet because the object isn't initialized.
  2. (Derived class only) super() is called, which creates the parent class instance.
  3. The current class's fields are initialized.
  4. The rest of the constructor body runs.

Here's an example that shows this order in action:

function log(label, value) {
  console.log(label, value);
  return value;
}

class Vehicle {
  wheels = log("Vehicle field initialized:", 4);

  constructor(brand) {
    console.log("Vehicle constructor, this:", this);
    this.brand = brand;
  }
}

class Car extends Vehicle {
  doors = log("Car field initialized:", 4);

  constructor(brand) {
    console.log("Before super()");
    super(brand);
    console.log("Car constructor, this:", this);
    this.type = "sedan";
  }
}

const myCar = new Car("Toyota");
console.log(myCar.doors);

Output:

Before super()
Vehicle field initialized: 4
Vehicle constructor, this: Car { wheels: 4 }
Car field initialized: 4
Car constructor, this: Car { wheels: 4, brand: 'Toyota', doors: 4 }
4

Notice how "Before super()" prints first, then the parent class field and constructor run, then the child class field initializes, and finally the child constructor body completes.

Instance Properties and Methods

Instance properties belong to each individual object created from the class. Instance methods are functions available on every instance.

class BankAccount {
  accountType = "savings"; // field with default value
  balance;                 // field without initializer (undefined)

  constructor(owner, initialBalance) {
    this.owner = owner;
    this.balance = initialBalance;
  }

  deposit(amount) {
    this.balance += amount;
    console.log(`Deposited ${amount}. New balance: ${this.balance}`);
  }

  withdraw(amount) {
    if (amount > this.balance) {
      console.log("Insufficient funds!");
      return;
    }
    this.balance -= amount;
    console.log(`Withdrew ${amount}. New balance: ${this.balance}`);
  }
}

const account = new BankAccount("Manak", 1000);
account.deposit(500);   // Deposited 500. New balance: 1500
account.withdraw(200);  // Withdrew 200. New balance: 1300
console.log(account.accountType); // "savings"

A few things to notice:

  • Fields without initializers are set to undefined.
  • You don't use let, const, or var to declare fields inside a class.
  • Instance methods like deposit and withdraw are added to the class's prototype, so all instances share the same function reference.

Arrow Functions vs Regular Methods

There's an important difference between arrow functions and regular methods when it comes to this:

class Timer {
  constructor() {
    this.seconds = 0;
  }

  // arrow function: 'this' is always the Timer instance
  startArrow = () => {
    console.log(this.seconds); // always works
  };

  // regular method: 'this' depends on how the method is called
  startRegular() {
    console.log(this.seconds); // might be undefined if called out of context
  }
}

const timer = new Timer();

// Both work fine when called on the object
timer.startArrow();   // 0
timer.startRegular(); // 0

// But when extracted as a standalone function...
const arrowFn = timer.startArrow;
const regularFn = timer.startRegular;

arrowFn();   // 0 (arrow function keeps its 'this')
regularFn(); // TypeError: Cannot read properties of undefined

Arrow function properties capture this from the surrounding scope (the class instance). Regular methods depend on how they're called. This matters a lot when passing methods as callbacks, like in event handlers.

Public Field Declarations

You can declare fields directly in the class body without using the constructor:

class TodoItem {
  completed = false;  // public field with default
  priority;           // public field, defaults to undefined

  constructor(text) {
    this.text = text;
  }
}

const todo = new TodoItem("Learn JavaScript classes");
console.log(todo.completed); // false
console.log(todo.priority);  // undefined
⚠️

Public instance fields are added using Object.defineProperty() at construction time in a base class (before the constructor body runs), or just after super() returns in a subclass. This is why they follow a specific initialization order.

Private Field Declarations

Private class members use a # prefix. They can only be accessed from inside the class.

class Wallet {
  #balance = 0;
  #pin;

  constructor(pin, initialBalance) {
    this.#pin = pin;
    this.#balance = initialBalance;
  }

  #validatePin(inputPin) {
    return inputPin === this.#pin;
  }

  checkBalance(inputPin) {
    if (!this.#validatePin(inputPin)) {
      console.log("Incorrect PIN!");
      return;
    }
    console.log(`Your balance is: $${this.#balance}`);
  }

  transfer(amount, inputPin) {
    if (!this.#validatePin(inputPin)) {
      console.log("Incorrect PIN!");
      return;
    }
    this.#balance -= amount;
    console.log(`Transferred $${amount}. Remaining: $${this.#balance}`);
  }
}

const myWallet = new Wallet(1234, 5000);
myWallet.checkBalance(1234);   // Your balance is: $5000
myWallet.transfer(500, 1234);  // Transferred $500. Remaining: $4500

// None of these work:
// myWallet.#balance;       // SyntaxError
// myWallet.#validatePin(); // SyntaxError

Some key rules about private members:

  • Referencing # names from outside the class is a syntax error. Not a runtime error, a syntax error. The code won't even parse.
  • You can't delete private fields with delete.
  • Private fields do not participate in inheritance. A child class cannot access the parent's private fields.
  • Unlike public methods, private methods are not on Class.prototype.

You can also have private static fields and methods:

class AppConfig {
  static #instance = null;

  static getInstance() {
    if (!AppConfig.#instance) {
      AppConfig.#instance = new AppConfig();
    }
    return AppConfig.#instance;
  }

  constructor() {
    this.debug = false;
  }
}

const config1 = AppConfig.getInstance();
const config2 = AppConfig.getInstance();
console.log(config1 === config2); // true (same instance)

Static Methods and Properties

The static keyword defines methods and properties that belong to the class itself, not to instances. You call them directly on the class.

class TemperatureConverter {
  static unit = "Celsius";

  static toFahrenheit(celsius) {
    return (celsius * 9) / 5 + 32;
  }

  static toCelsius(fahrenheit) {
    return ((fahrenheit - 32) * 5) / 9;
  }
}

// Called on the class, not on an instance
console.log(TemperatureConverter.toFahrenheit(100)); // 212
console.log(TemperatureConverter.toCelsius(32));     // 0
console.log(TemperatureConverter.unit);              // "Celsius"

// Cannot be called on instances
const converter = new TemperatureConverter();
console.log(converter.toFahrenheit); // undefined
console.log(converter.unit);        // undefined

Static methods are useful for utility functions that don't need instance data. Think of helper methods, factory functions, or constants that belong to the class concept, not individual objects.

A Practical Example: Factory Method

class User {
  constructor(name, role) {
    this.name = name;
    this.role = role;
  }

  static createAdmin(name) {
    return new User(name, "admin");
  }

  static createGuest() {
    return new User("Guest", "guest");
  }
}

const admin = User.createAdmin("Manak");
const guest = User.createGuest();

console.log(admin.role); // "admin"
console.log(guest.name); // "Guest"

Getters and Setters

Getters and setters let you define methods that behave like properties. You access them without parentheses.

class Circle {
  #radius;

  constructor(radius) {
    this.#radius = radius;
  }

  get radius() {
    return this.#radius;
  }

  set radius(value) {
    if (value <= 0) {
      console.log("Radius must be positive!");
      return;
    }
    this.#radius = value;
  }

  get area() {
    return Math.PI * this.#radius ** 2;
  }

  get circumference() {
    return 2 * Math.PI * this.#radius;
  }
}

const circle = new Circle(5);
console.log(circle.radius);        // 5 (getter called)
console.log(circle.area);          // 78.539...
console.log(circle.circumference); // 31.415...

circle.radius = 10;                // setter called
console.log(circle.area);          // 314.159...

circle.radius = -1;                // "Radius must be positive!"

Getters are great for computed properties (like area). Setters are great for adding validation when a property is changed.

Inheritance with extends

The extends keyword creates a child class that inherits from a parent class. The child gets all the parent's methods and can override them or add new ones.

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(`${this.name} makes a noise.`);
  }

  sleep() {
    console.log(`${this.name} is sleeping.`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name); // call the parent constructor
    this.breed = breed;
  }

  // Override the parent's speak method
  speak() {
    console.log(`${this.name} barks.`);
  }

  fetch(item) {
    console.log(`${this.name} fetches the ${item}!`);
  }
}

class Cat extends Animal {
  speak() {
    console.log(`${this.name} meows.`);
  }
}

const dog = new Dog("Buddy", "Golden Retriever");
dog.speak();       // Buddy barks.
dog.sleep();       // Buddy is sleeping. (inherited from Animal)
dog.fetch("ball"); // Buddy fetches the ball!

const cat = new Cat("Whiskers");
cat.speak();       // Whiskers meows.
cat.sleep();       // Whiskers is sleeping. (inherited from Animal)

Rules of Inheritance

  • If a subclass has a constructor, it must call super() before using this.
  • If a subclass doesn't define a constructor, JavaScript creates a default one that calls super() with all arguments.
  • A child class can only extend one parent (no multiple inheritance).
  • Any constructor that can be called with new and has a prototype property can be a parent class.

Extending Old-Style Constructor Functions

Classes can even extend traditional constructor functions. This is useful when working with older codebases:

function LegacyLogger() {
  this.logs = [];
}

LegacyLogger.prototype.log = function (message) {
  this.logs.push(message);
  console.log(`[LOG]: ${message}`);
};

class EnhancedLogger extends LegacyLogger {
  warn(message) {
    this.logs.push(`[WARN] ${message}`);
    console.log(`[WARN]: ${message}`);
  }
}

const logger = new EnhancedLogger();
logger.log("Server started");   // [LOG]: Server started
logger.warn("Memory usage high"); // [WARN]: Memory usage high
console.log(logger.logs);
// ["Server started", "[WARN] Memory usage high"]

this in Static and Prototype Methods

When a method is called without a proper context, this will be undefined (because class bodies are in strict mode). This catches bugs early:

class Printer {
  name = "Office Printer";

  print() {
    console.log(`Printing from: ${this.name}`);
  }
}

const printer = new Printer();
printer.print(); // Printing from: Office Printer

const detachedPrint = printer.print;
detachedPrint(); // TypeError: Cannot read properties of undefined (reading 'name')

Instance Fields and super

Instance fields live on the instance, not on the prototype. Because of this, you cannot use super to access a parent's instance fields from a child class:

class Shape {
  sides = 4;
}

class Square extends Shape {
  sideLength = super.sides; // undefined! Not what you expect.
}

const square = new Square();
console.log(square.sideLength); // undefined
console.log(square.sides);     // 4 (inherited, but not via super)

super only accesses the parent's prototype, and instance fields are not on the prototype. If you need to access a parent's data, use methods or the constructor instead.

Putting It All Together

Here's a more complete example that uses most of the features we've covered:

class Shape {
  #color;

  constructor(color = "black") {
    this.#color = color;
  }

  get color() {
    return this.#color;
  }

  set color(value) {
    this.#color = value;
  }

  describe() {
    return `A ${this.#color} shape`;
  }

  static create(type, ...args) {
    switch (type) {
      case "rectangle":
        return new Rectangle(...args);
      case "circle":
        return new CircleShape(...args);
      default:
        throw new Error(`Unknown shape: ${type}`);
    }
  }
}

class Rectangle extends Shape {
  #width;
  #height;

  constructor(width, height, color) {
    super(color);
    this.#width = width;
    this.#height = height;
  }

  get area() {
    return this.#width * this.#height;
  }

  describe() {
    return `${super.describe()} rectangle (${this.#width}x${this.#height})`;
  }
}

class CircleShape extends Shape {
  #radius;

  constructor(radius, color) {
    super(color);
    this.#radius = radius;
  }

  get area() {
    return Math.PI * this.#radius ** 2;
  }

  describe() {
    return `${super.describe()} circle (r=${this.#radius})`;
  }
}

// Using the factory method
const rect = Shape.create("rectangle", 10, 5, "blue");
console.log(rect.describe()); // A blue shape rectangle (10x5)
console.log(rect.area);       // 50

const circ = Shape.create("circle", 7, "red");
console.log(circ.describe()); // A red shape circle (r=7)
console.log(circ.area);       // 153.938...

Wrapping Up

JavaScript classes give you a clean syntax for working with objects and inheritance. Understanding how constructors, fields, static members, private fields, and inheritance work together will make you much more confident writing object-oriented JavaScript.

The key takeaways:

  • Use # prefix for private fields when you need true encapsulation.
  • Static methods belong to the class, not instances. Great for utilities and factories.
  • Arrow function properties keep their this binding. Regular methods don't.
  • Instance fields are on the object, not the prototype. So super can't access them.

If you have any questions feel free to reach out to me on LinkedIn.