JavaScript Classes: Constructor, Fields, and #Private

JavaScript classes give you a cleaner way to define reusable object blueprints. In this article, you'll learn how class constructors work, how public fields are initialized, and how #private fields protect data inside an instance.

Quick answer: A JavaScript class is syntax for creating constructor functions and methods in a more readable way. Use the constructor to initialize each instance, public fields for values you want to expose, and #private fields for data that should not be accessed from outside the class.

Difficulty: Beginner

You'll understand this better if you know: how objects work, how functions receive arguments, and the difference between instance data and shared methods.

1. What Is JavaScript Classes: Constructor, Fields, and #Private?

A JavaScript class is a special kind of function syntax for creating objects with shared behavior. It lets you define:

Classes do not replace objects or prototypes. They provide a clearer way to work with them. Under the hood, a class still uses JavaScript's prototype system.

This topic matters because classes are common in modern JavaScript codebases, especially when modeling things like users, carts, timers, API clients, and UI state managers.

2. Why JavaScript Classes: Constructor, Fields, and #Private Matter

Classes help you organize related data and behavior in one place. Instead of passing the same values into many separate functions, you can create an object once and let its methods operate on its own state.

They are especially useful when you need:

Use classes when your code naturally models stateful things. Do not use them just because they look modern. For simple data transformations or one-off utilities, plain functions may be better.

3. Basic Syntax or Core Idea

A class typically contains a constructor, fields, and methods. The constructor runs when you create a new instance with new.

Minimal class example

Here is the smallest useful shape of a class with one field and one method.

class Counter {
  count = 0;

  constructor() {
    // Runs when a new instance is created
  }

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

const counter = new Counter();
counter.increment();

In this example, count is a public field, constructor is the setup step, and increment() is a method that changes instance state.

How the pieces fit together

4. Step-by-Step Examples

Example 1: Passing values into the constructor

Use the constructor to accept values that every instance needs. This is the most common pattern for initializing class state.

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

  describe() {
    return `${this.name} <${this.email}>`;
  }
}

const user = new User("Ava", "[email protected]");

This pattern is useful when each object needs its own initial data. The constructor copies the arguments onto the instance so methods can use them later.

Example 2: Public class fields with default values

Public fields are declared directly in the class body. They are created for each instance before the constructor finishes.

class Timer {
  seconds = 0;
  running = false;

  start() {
    this.running = true;
  }
}

Use fields like this when a property always exists and should begin with a default value. It makes the class easier to read because the shape of the object is visible immediately.

Example 3: #private fields for internal state

Fields that start with # are private. They are only accessible inside the class body.

class BankAccount {
  #balance = 0;

  deposit(amount) {
    this.#balance += amount;
  }

  getBalance() {
    return this.#balance;
  }
}

const account = new BankAccount();
account.deposit(50);

Outside the class, account.#balance is not allowed. That makes private fields a strong choice for internal data that must be protected from accidental access.

Example 4: Constructor with validation

Constructors can validate input before storing it. This helps catch invalid state as early as possible.

class Product {
  constructor(name, price) {
    if (typeof name !== "string") {
      throw new TypeError("name must be a string");
    }

    if (typeof price !== "number" || price <= 0) {
      throw new RangeError("price must be a positive number");
    }

    this.name = name;
    this.price = price;
  }
}

This pattern is helpful when a class should never exist in an invalid state. The constructor becomes the gatekeeper for acceptable values.

5. Practical Use Cases

Classes are a good fit when the object needs both data and behavior over time. They are less useful for simple data formatting or single-purpose helper logic.

6. Common Mistakes

Mistake 1: Forgetting to use new

Classes must be instantiated with new. Calling a class like a normal function causes a runtime error.

Problem: The class constructor is not being called as a constructor, so JavaScript rejects the call.

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

const user = User("Ava");

Fix: Create the instance with new.

const user = new User("Ava");

The corrected version works because new creates an object and binds this inside the constructor.

Mistake 2: Trying to access a private field from outside

Private fields use a hard privacy boundary. Code outside the class cannot read or write them.

Problem: Accessing a #private field from outside the class throws a syntax error because the field is not part of the public object shape.

class Account {
  #balance = 100;
}

const account = new Account();
console.log(account.#balance);

Fix: Expose a method if outside code needs read access.

class Account {
  #balance = 100;

  getBalance() {
    return this.#balance;
  }
}

const account = new Account();
console.log(account.getBalance());

The fix works because public methods can safely reveal information without exposing the internal field directly.

Mistake 3: Losing this when passing a method

Class methods rely on this pointing to the instance. If you pass a method as a callback, that connection can be lost.

Problem: The method is detached from its instance, so this becomes undefined in strict mode and the code can fail with a TypeError.

class Counter {
  count = 0;

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

const counter = new Counter();
const run = counter.increment;
run();

Fix: Bind the method or wrap it in an arrow function when passing it around.

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

The corrected version works because bind permanently links the method to the original instance.

7. Best Practices

Use fields for clear instance shape

Declare the properties your class expects near the top of the class body. That makes the object shape obvious and avoids surprises later.

class Session {
  token = null;
  expiresAt = null;
}

This is better than adding properties in unrelated methods because readers can see the instance structure immediately.

Keep validation close to construction

If a class should never represent invalid data, enforce rules in the constructor or dedicated setters.

class Rectangle {
  constructor(width, height) {
    if (width <= 0 || height <= 0) {
      throw new RangeError("width and height must be positive");
    }

    this.width = width;
    this.height = height;
  }
}

Early validation prevents invalid objects from spreading through your program.

Use #private for enforced encapsulation, not just convention

Underscore-prefixed properties like _value are only a naming convention. If you need true privacy, use #private fields.

class Cache {
  #store = new Map();

  set(key, value) {
    this.#store.set(key, value);
  }
}

This is more reliable because outside code cannot accidentally depend on the internal field.

8. Limitations and Edge Cases

If a class field seems to be missing, check whether you meant to set a prototype method, an instance field, or a private field. Those three look similar but behave differently.

9. Practical Mini Project

Let's build a small task tracker class that uses a constructor, public fields, and a private field for internal state. This example shows how the parts work together in a realistic object.

class TaskTracker {
  #tasks = [];
  owner;

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

  addTask(title) {
    this.#tasks.push({
      id: crypto.randomUUID(),
      title,
      done: false
    });
  }

  markDone(id) {
    const task = this.#tasks.find((item) => item.id === id);
    if (task) {
      task.done = true;
    }
  }

  list() {
    return this.#tasks.map((task) => `${task.done ? "[x]" : "[ ]"} ${task.title}`);
  }
}

const tracker = new TaskTracker("Mina");
tracker.addTask("Write documentation");
tracker.addTask("Review examples");
console.log(tracker.list().join("\n"));

This mini project uses a public owner field, a private #tasks array, and methods to modify and read the internal list safely. The private field keeps task data hidden while still allowing the class to manage it internally.

10. Key Points

11. Practice Exercise

Build a small Playlist class.

Expected output: creating a playlist, adding songs, and reading them back should work, but direct access to the private song list should fail.

Hint: Use #songs = [] inside the class and return a copy from getSongs().

Solution:

class Playlist {
  #songs = [];
  title;

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

  addSong(song) {
    this.#songs.push(song);
  }

  removeSong(song) {
    this.#songs = this.#songs.filter((item) => item !== song);
  }

  getSongs() {
    return [...this.#songs];
  }
}

const playlist = new Playlist("Road Trip");
playlist.addSong("Sunrise Drive");
playlist.addSong("Night Lights");
console.log(playlist.getSongs());

This solution keeps the song list private while still giving outside code a safe way to use it.

12. Final Summary

JavaScript classes provide a clear way to group initialization, data, and behavior into one object definition. The constructor handles setup, public fields define instance data, and methods describe what the object can do.

#private fields are especially valuable when a class owns internal state that should not be touched from the outside. They give you real encapsulation, not just a naming convention. That makes classes more robust when you want to protect invariants such as balances, counters, caches, or task lists.

If you want to go further, the next useful topics are inheritance with extends and super, static methods, and the difference between class methods and arrow-function fields.