JavaScript Fundamentals: Classes in JavaScript
Classes are a fundamental building block of object-oriented programming in JavaScript. This tutorial covers the JavaScript fundamentals of classes: how to declare them, work with constructors and methods, implement inheritance, and use private fields — everything you need to write clean, encapsulated object-oriented code in modern JavaScript.
What are classes?
At their core, classes are blueprints for creating objects. They define properties and methods that all instances of the class will have. Think of a class as a recipe — it tells JavaScript how to create something, but you need to actually make the dish (create an instance) to use it.
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() {
return `Hello, my name is ${this.name}`;
}
}
const alice = new Person("Alice", 30);
console.log(alice.greet());
// Hello, my name is Alice
Class declaration and expression
You can declare a class using the class keyword, similar to function declarations. There’s also a class expression form, which is less common but useful in certain situations — for example, when you need to pass a class as an argument or assign it conditionally. A class declaration is hoisted-like in that the class name is available inside the class body, but unlike function declarations, class declarations are not initialized until the declaration line runs:
// Class declaration
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
}
// Class expression
const Square = class {
constructor(side) {
this.side = side;
}
area() {
return this.side * this.side;
}
};
const rect = new Rectangle(10, 5);
console.log(rect.area()); // 50
const sq = new Square(7);
console.log(sq.area()); // 49
Constructor
The constructor is a special method called when you create a new instance with new. It’s where you initialize the object’s properties. A class can only have one constructor. If you omit it, JavaScript provides an empty default constructor. The constructor is also where you validate inputs and set up initial state before any methods run:
class BankAccount {
constructor(balance = 0) {
this.balance = balance;
this.transactions = [];
}
deposit(amount) {
if (amount <= 0) {
throw new Error("Deposit amount must be positive");
}
this.balance += amount;
this.transactions.push({ type: "deposit", amount });
return this.balance;
}
withdraw(amount) {
if (amount > this.balance) {
throw new Error("Insufficient funds");
}
this.balance -= amount;
this.transactions.push({ type: "withdraw", amount });
return this.balance;
}
}
const account = new BankAccount(100);
account.deposit(50);
account.withdraw(30);
console.log(account.balance); // 120
Methods
Methods are functions defined inside a class. They can access and modify the object’s properties via this. JavaScript classes support several types of methods.
Instance Methods
These are the most common — they’re available on every instance of the class.
class Temperature {
constructor(celsius) {
this.celsius = celsius;
}
toFahrenheit() {
return (this.celsius * 9/5) + 32;
}
toKelvin() {
return this.celsius + 273.15;
}
}
const temp = new Temperature(25);
console.log(temp.toFahrenheit()); // 77
console.log(temp.toKelvin()); // 298.15
Instance methods like toFahrenheit() and toKelvin() work with this to access the instance’s own data. They are the bread and butter of class design: every instance gets its own copy of these methods via the prototype chain.
Getters and Setters
Getters and setters let you define computed properties and add validation when setting values. A getter looks like a property access but runs a function. A setter intercepts property assignment, which is useful for validating input before it reaches internal state:
class User {
constructor(name) {
this._name = name;
this._loginAttempts = 0;
}
get name() {
return this._name;
}
set name(value) {
if (typeof value !== "string" || value.length < 2) {
throw new Error("Name must be a string with at least 2 characters");
}
this._name = value;
}
get isLocked() {
return this._loginAttempts >= 3;
}
recordFailedLogin() {
this._loginAttempts++;
}
}
const user = new User("Bob");
console.log(user.name); // Bob
user.name = "Alice";
console.log(user.name); // Alice
user.recordFailedLogin();
user.recordFailedLogin();
user.recordFailedLogin();
console.log(user.isLocked); // true
The User class uses getters to expose a computed isLocked property and a setter to validate the name before assignment. This keeps validation logic close to the data it protects rather than scattered across call sites.
Static Methods
Static methods belong to the class itself, not to instances. They’re useful for utility functions related to the class. You call them on the class name directly, like MathUtils.average(), and they cannot access instance properties because they don’t receive a this bound to an instance:
class MathUtils {
static average(...numbers) {
if (numbers.length === 0) return 0;
return numbers.reduce((a, b) => a + b, 0) / numbers.length;
}
static clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
}
console.log(MathUtils.average(1, 2, 3, 4, 5)); // 3
console.log(MathUtils.clamp(15, 0, 10)); // 10
console.log(MathUtils.clamp(-5, 0, 10)); // 0
Static methods like average() and clamp() make sense on the class because they don’t need instance data; they operate entirely on their arguments. This keeps helper logic organized under a clear namespace instead of floating as standalone functions.
Static Properties
Static properties (added in ES2022) work similarly to static methods — they belong to the class, not instances. They are useful for shared counters, configuration defaults, or any value that should be tracked at the class level rather than per instance:
class Counter {
static count = 0;
constructor() {
Counter.count++;
}
static reset() {
Counter.count = 0;
}
}
new Counter();
new Counter();
new Counter();
console.log(Counter.count); // 3
Counter.reset();
console.log(Counter.count); // 0
The Counter example tracks how many instances have been created. Each new Counter() call increments Counter.count, and Counter.reset() sets it back to zero. This pattern is common for caches, connection pools, and singleton tracking.
Inheritance with extends
Inheritance lets you create a new class based on an existing one. The child class inherits all properties and methods from the parent and can add new ones or override existing behavior. This is useful when you have a clear “is-a” relationship: a Dog is an Animal, so it makes sense for Dog to extend Animal and inherit shared behaviour:
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} makes a sound`;
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // Call parent constructor
this.breed = breed;
}
speak() {
return `${this.name} barks`;
}
fetch() {
return `${this.name} fetches the ball`;
}
}
const dog = new Dog("Rex", "German Shepherd");
console.log(dog.speak()); // Rex barks
console.log(dog.fetch()); // Rex fetches the ball
console.log(dog.breed); // German Shepherd
The super keyword calls the parent class’s constructor or methods. It must be called before accessing this in the constructor. In the Dog example, super(name) passes the name up to the Animal constructor, and speak() is overridden to return a bark instead of the generic sound. This override mechanism is how child classes specialize behaviour without rewriting the parent.
Private Fields
Private fields (prefixed with #) restrict access to class internals. They can’t be accessed or modified from outside the class, enforcing encapsulation. Unlike the underscore convention (_balance), private fields are enforced by the JavaScript engine itself — any attempt to read or write them from outside the class throws a syntax error:
#balance;
#transactions;
constructor(initialBalance = 0) {
this.#balance = initialBalance;
this.#transactions = [];
}
deposit(amount) {
if (amount <= 0) return false;
this.#balance += amount;
this.#transactions.push({ type: "deposit", amount });
return true;
}
getBalance() {
return this.#balance;
}
getTransactions() {
return [...this.#transactions];
}
}
const account = new BankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.getTransactions());
// [{ type: "deposit", amount: 50 }]
// These would throw errors:
// console.log(account.#balance);
// account.#balance = 1000000;
Private fields give you genuine encapsulation. The getBalance() method is the only way to read the balance, and deposit() is the only way to add funds. Even subclasses cannot access #balance directly — unlike protected fields in languages like Java, JavaScript private fields are truly private to the defining class.
Class Expression and instanceof
The instanceof operator checks if an object belongs to a class (or its parent classes). It walks the prototype chain, so an instance of a child class is also an instance of its parent:
class Vehicle {
constructor(type) {
this.type = type;
}
}
class Car extends Vehicle {
constructor(brand) {
super("car");
this.brand = brand;
}
}
const myCar = new Car("Toyota");
console.log(myCar instanceof Car); // true
console.log(myCar instanceof Vehicle); // true
console.log(myCar instanceof Object); // true
Quick Reference
Here’s a summary of class features:
| Feature | Syntax | Description |
|---|---|---|
| Declaration | class Name { } | Create a class |
| Constructor | constructor(props) { } | Initialize instances |
| Method | methodName() { } | Instance method |
| Getter | get prop() { } | Computed property |
| Setter | set prop(val) { } | Validated assignment |
| Static | static method() { } | Class-level method |
| Private | #field | Encapsulated field |
| Inheritance | class Child extends Parent | Extend a class |
| Parent call | super.method() | Call parent method |
Summary
Thinking In Objects
Classes are useful when you want to bundle data and behavior into a single unit that reads like a real thing in the domain. A BankAccount, User, or Temperature class is easier to understand when the methods describe what the object can do rather than how it stores the data. That makes code easier to scan because the shape of the object matches the mental model of the feature. It also gives you a place to group logic that belongs together instead of scattering it across unrelated helpers.
This is also why classes fit well when several methods share the same internal state. A constructor can set up that state once, and the instance methods can rely on it afterward. When that pattern feels natural, the code often becomes cleaner than passing the same values through many standalone functions. The key is to keep the class focused. If it starts to collect unrelated behavior, the object model is telling you that the design may need to be split.
Inheritance And Composition
Inheritance is powerful, but it is not always the best first choice. A child class should make sense as a more specific version of its parent. If that relationship feels forced, composition may be easier to maintain. For example, a class can use another class as a field instead of inheriting from it. That approach keeps responsibilities separated and often makes the code easier to change when the feature grows.
It also helps to remember that classes are only one tool in JavaScript. They are great for clear object models, but they are not required for every abstraction. Some tasks are better served by factory functions, modules, or plain objects. Choosing the lighter option when it is enough keeps the code more direct and reduces the amount of ceremony future readers have to carry around in their heads.
Keeping instances predictable
Predictable classes have simple constructors, clear method names, and state that does not surprise the caller. If a method mutates the instance, make that behavior obvious. If a method returns a new value without changing state, that should be clear too. The fewer hidden side effects there are, the easier the class is to test and the easier it is to use correctly. A good class feels steady: you know what it owns, what it changes, and what it leaves alone.
Classes in JavaScript provide a clean, object-oriented way to structure your code. Key takeaways:
- Use
classkeyword to declare classes,constructorfor initialization - Methods, getters, setters, and static methods add behavior
- Private fields (
#) enforce encapsulation extendscreates inheritance,superaccesses parent class- Classes are just syntactic sugar over JavaScript’s prototype system
Understanding classes is essential for working with many JavaScript frameworks and writing maintainable, reusable code.
Final class note
Classes are easiest to maintain when they stay close to the problem they model. A class that grows into a catch-all container for too many unrelated responsibilities becomes hard to test and hard to read. Keep the public methods clear, keep the constructor simple, and split the class if the shape of the object starts to feel fuzzy.
That discipline is what keeps class-based code useful over time instead of turning it into a hard-to-change pile of methods.
When the class still maps cleanly to the thing it models, the code stays easier to use and easier to reason about later.
That is the real value of the pattern in day-to-day work.