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:
- a constructor for one-time setup when a new object is created,
- public fields for data stored on each instance,
- methods for behavior shared by all instances, and
- #private fields for internal state that only class code can access.
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:
- multiple objects with the same shape and behavior,
- clear initialization logic,
- state that should not be changed directly, and
- code that is easier to read than prototype-based constructor functions.
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
- class Counter defines the blueprint.
- new Counter() creates an object from that blueprint.
- this.count refers to the current instance.
- increment() is stored on the prototype, so every instance shares one copy of the method.
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
- API clients: store base URLs, authentication state, and helper methods in one object.
- UI widgets: keep DOM references, internal counters, and event logic together.
- Game objects: model position, velocity, health, and behavior.
- Form helpers: manage validation state and derived values.
- Business entities: represent users, invoices, carts, or subscriptions.
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
- Private fields are enforced by the language, so they cannot be accessed with bracket notation or dynamic property names.
- Each class with #private fields creates a separate private namespace. A field named #value in one class is unrelated to #value in another class.
- Class fields are instance-specific, so storing a function as a field creates one copy per instance rather than one shared prototype method.
- Methods defined in the class body are non-enumerable and live on the prototype, which can affect object inspection and iteration expectations.
- Classes are always in strict mode, so mistakes like using an unbound method often fail more clearly than in older function patterns.
- You cannot declare a private field outside the class body, and you cannot reference it before it is declared in the class definition.
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
- A class is a blueprint for creating objects with shared behavior.
- The constructor runs when you create an instance with new.
- Public fields store per-instance data that outside code can access.
- #private fields can only be used inside the class body.
- Class methods live on the prototype and are shared between instances.
- Use classes when the object has meaningful state and behavior.
11. Practice Exercise
Build a small Playlist class.
- Create a constructor that accepts a playlist name.
- Add a public field for the playlist title.
- Store songs in a private #songs field.
- Add methods to addSong(), removeSong(), and getSongs().
- Ensure outside code cannot modify the private songs array directly.
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.