Tutorials

JavaScript ES6 Classes

Key Takeaways

  • JavaScript classes use prototype-based inheritance; they don't replace JavaScript's object model.
  • Public instance methods declared in a class body live on its prototype, while instance fields belong to individual objects.
  • Static methods belong to the class constructor and receive no instance automatically.
  • In a derived constructor, call super() before accessing this.
  • Private fields use language-enforced # names. Classes are useful, but factories and composition remain valid alternatives.

JavaScript classes provide syntax for defining constructors, methods, static utilities, and inheritance on top of JavaScript's prototype-based object model. They're one way to organize objects and behavior—not an inherently better replacement for factory functions or composition.

Class syntax was introduced in ECMAScript 2015, also called ES6. This guide starts with that syntax, then covers later additions such as public fields, private elements, and static initialization blocks. For the surrounding language features, see our JavaScript ES6 introduction.

Creating a Class

Use class to define a class and new to construct an instance: an object created through that class. The constructor is the class's special initialization method. In this example, this refers to the instance being initialized.

Each JavaScript block below is a standalone example. Run blocks separately in an environment that supports class fields.

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
}

const person = new Person("Sam", 30);

console.log(person.name); // Sam
console.log(person.age);  // 30

The assignments create name and age properties on this particular object. Calling a class constructor without new throws a TypeError.

Omitted constructors, declarations, and strict mode

You don't have to write a constructor. A base class gets a default constructor with an empty body; a derived class gets one that forwards its arguments to the parent constructor. Declared instance fields are still initialized when you omit the constructor.

Unlike function declarations, class declarations aren't usable before execution reaches their declaration. Their binding is in a temporal dead zone—the interval before initialization—and accessing it then throws a ReferenceError. Class bodies also execute in strict mode automatically.

Defining Properties and Methods

A property stores a value on an object; a method is a function used as a property. A public class field, such as active = true, declares an instance property directly in the class body. For a refresher on properties, see our JavaScript objects tutorial.

class Person {
  active = true;

  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  sayName() {
    console.log(`My name is ${this.name}`);
  }
}

const person = new Person("Tim", 40);
const other = new Person("Sam", 30);

person.sayName(); // My name is Tim

console.log(Object.hasOwn(person, "active")); // true
console.log(Object.hasOwn(person, "sayName")); // false
console.log(Object.hasOwn(Person.prototype, "sayName")); // true
console.log(Object.getPrototypeOf(person) === Person.prototype); // true
console.log(person.sayName === other.sayName); // true

The two instances share the same sayName function through Person.prototype. They don't share their name, age, or active properties. A prototype is another object that JavaScript searches when a property isn't found on the object itself.

Object.hasOwn(object, property) checks only the object itself, not its prototype chain. Object.getPrototypeOf(object) returns its prototype.

Instance field initializers run for each new instance. A field declared without an initializer starts as undefined.

Adding properties and functions at runtime

You can also modify ordinary, extensible instances after construction. The following example adds data and a function to just one instance, then deletes the data property.

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

const person = new Person("Tim");
const other = new Person("Sam");

person.role = "programmer";
person.sayHello = function () {
  console.log(`Hello from ${this.name}`);
};

person.sayHello(); // Hello from Tim
console.log(other.sayHello); // undefined

delete person.role;
console.log(person.role); // undefined

This doesn't change the class's shared methods. Replacing Person.prototype.sayName would instead affect instances whose method lookup reaches that property. Keep shared behavior in the class definition rather than using runtime prototype changes as an extension mechanism.

Static Functions: Methods and Fields

A static method is a property of the class constructor, not an instance method on its prototype. Call it through the class. Named factories—methods that create objects from a particular input format—are a useful application.

class Person {
  static kind = "person";

  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  static describe() {
    console.log(`This is a ${this.kind}.`);
  }

  static fromRecord(record) {
    return new Person(record.name, record.age);
  }
}

Person.describe(); // This is a person.

const person = Person.fromRecord({ name: "Sam", age: 30 });
console.log(person.name); // Sam
console.log(Person.kind); // person
console.log(person.fromRecord); // undefined

In Person.describe(), this is Person, so the method can read the static kind field. A static method receives no instance automatically; it doesn't automatically inspect or update every object created by the class. The factory above explicitly creates a Person.

DeclarationWhere it livesTypical access
Public instance fieldEach instanceperson.name
Public instance methodPerson.prototypeperson.sayName()
Public static field or methodThe Person constructorPerson.kind or Person.fromRecord(record)

These locations explain the distinction: instance state belongs to objects, shared instance behavior is found through the prototype, and static members are accessed through the constructor.

Static initialization blocks

For multi-statement class-level setup, a static initialization block uses the syntax static { /* setup */ }. It runs during class evaluation, not every time you construct an instance. Static field initializers and static blocks run in declaration order, and blocks can access the class's private elements. Prefer a field initializer when one expression is enough.

JavaScript Class Inheritance with extends

The extends keyword establishes inheritance through prototype links; it doesn't copy a parent's instance properties into a child class. A subclass can reuse an inherited method or override it by declaring a method with the same name.

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  sayName() {
    console.log(`My name is ${this.name}`);
  }
}

class Programmer extends Person {
  sayName() {
    console.log(`My name is ${this.name} and I am a programmer!`);
  }
}

const programmer = new Programmer("Sam", 33);
programmer.sayName();
// My name is Sam and I am a programmer!

console.log(programmer.age); // 33
console.log(Object.getPrototypeOf(Programmer.prototype) === Person.prototype);
// true
console.log(Object.getPrototypeOf(Programmer) === Person); // true

Because Programmer omits a constructor, its default constructor forwards "Sam" and 33 to Person. The parent constructor initializes the child's instance properties. The separate constructor-to-constructor link also enables inheritance of public static members.

Instance method lookup:
programmer { name: "Sam", age: 33 }
  -> Programmer.prototype { sayName: overriding method }
  -> Person.prototype { sayName: parent method }
  -> Object.prototype
  -> null

Constructor relationships:
Programmer -> Person                 (static inheritance)
Person.prototype.constructor -> Person

Here, programmer.sayName() finds the override on Programmer.prototype first. Without that override, lookup would continue to Person.prototype.

Using super for Constructors and Parent Methods

super() invokes the parent constructor. In a derived constructor, you must call it before using this. By contrast, super.sayName() calls the parent implementation while keeping the current instance as this.

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  sayName() {
    console.log(`My name is ${this.name}`);
  }
}

class Programmer extends Person {
  constructor(name, age, language) {
    super(name, age);
    this.language = language;
  }

  sayName() {
    super.sayName();
    console.log(`I write code in ${this.language}`);
  }
}

const programmer = new Programmer("Sam", 33, "JavaScript");
programmer.sayName();
// My name is Sam
// I write code in JavaScript

You can validate constructor arguments before super(), provided that code doesn't access this. Base-class fields initialize before the base constructor body; derived-class fields initialize immediately after super() returns.

Private Class Fields and Methods

Prefix a private field or method with #. Unlike an underscore naming convention, private names have language-enforced access restrictions. This standalone example uses a private field for state and a private method for validation.

class Programmer {
  #language;

  constructor(language) {
    this.#validateLanguage(language);
    this.#language = language;
  }

  #validateLanguage(language) {
    if (typeof language !== "string" || language.length === 0) {
      throw new TypeError("language must be a non-empty string");
    }
  }

  sayLanguage() {
    console.log(`I write code in ${this.#language}`);
  }
}

const programmer = new Programmer("JavaScript");
programmer.sayLanguage(); // I write code in JavaScript

// Invalid in normal source code:
// console.log(programmer.#language); // SyntaxError

Private names must be declared in the class. Code outside their declaring class's scope cannot access them directly, and subclass code cannot directly access a parent's private names. A subclass can still call inherited public methods that use the parent's private state.

Keeping this When Passing Methods as Callbacks

Class methods aren't automatically bound to their instances. Extracting a method and calling it as a standalone function gives it undefined as this; a DOM event handler may instead receive the element as this. Neither preserves the original instance automatically.

class Counter {
  count = 0;

  increment() {
    this.count += 1;
  }

  incrementArrow = () => {
    this.increment();
  };
}

const counter = new Counter();
const detached = counter.increment;
// detached(); // TypeError: this is undefined

const bound = counter.increment.bind(counter);
bound();

const wrapped = () => counter.increment();
wrapped();

const arrow = counter.incrementArrow;
arrow();

console.log(counter.count); // 3

bind() creates a function with a chosen this. A wrapper calls the method through its object, while an arrow-function instance field captures the instance's this. Our JavaScript functions tutorial covers the surrounding function syntax.

The trade-off: each arrow-function field creates a function per instance, rather than sharing one prototype method. Each call to bind() also creates a new function. Prefer prototype methods by default; use a stable bound or arrow callback when you need instance-specific binding.

Choosing Classes, Factories, or Composition

Use a class when objects have state and related operations, and a shared prototype API makes the code easier to understand. A factory function—a function that returns an object—can be simpler for a plain record or a small configurable object. Neither approach requires inheritance.

Composition means building behavior by combining objects or functions rather than extending a parent class. For example, give a person a formatter object instead of creating a subclass solely to change output formatting. Prefer the design with fewer dependencies between otherwise unrelated behaviors.

Common Mistakes

  • Confusing instance changes with class changes. Adding a function to one instance doesn't add it to the prototype or other instances.
  • Treating static methods as instance methods. Call Person.fromRecord(record), not person.fromRecord(record), for the factory above.
  • Assuming class syntax validates input. Validate untrusted records at your application boundary; the introductory Person constructors above only assign their arguments.

FAQ

Are JavaScript classes just syntactic sugar over prototypes?

JavaScript classes use the existing prototype-based inheritance model, but “just syntactic sugar” leaves out important rules. Class bodies use strict mode, class constructors require new, and private elements have access restrictions that ordinary public properties don't.

What is the difference between a static method and an instance method in JavaScript?

A static method belongs to the class constructor and is normally called through the class. A public instance method declared in the class body lives on the prototype and is normally called through an instance; it isn't recreated for every instance.

When do you need to call super() in a JavaScript class?

When you write a derived constructor that uses this, call super() before accessing this. If you omit the derived constructor, the default constructor forwards its arguments to the parent; use super.method() separately when you want a parent method's implementation.

Can class instances be modified at runtime?

Yes, ordinary extensible instances can have public properties added or deleted. That changes the instance, not the shared class methods; changing a prototype property can affect all instances whose property lookup reaches it.

Should I use a class or a factory function in JavaScript?

Choose a class when a shared prototype API and stateful operations make your objects easier to work with. Choose a factory when returning and configuring an object is simpler, and don't introduce inheritance solely to reuse a few functions.

Further Reading

Written by Sam Erickson

Software engineer. I write every article on StackChief myself. Author of Mastering the Tech Interview. More about me