Introduced in ES6 (2015), JavaScript classes provide a cleaner, more familiar syntax for creating objects and implementing inheritance. While they look like classes in languages like Java or Python, JavaScript classes are syntactic sugar over the existing prototype-based inheritance system. Understanding this distinction is crucial — classes make the code more readable and organized, but the underlying mechanics remain prototypal.
Key Insight: Classes in JavaScript are first-class citizens — they are functions under the hood. You can pass them as arguments, return them from functions, and assign them to variables, just like any other function.
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() {
return `Hello, I'm ${this.name}`;
}
haveBirthday() {
this.age++;
return this.age;
}
}
const alice = new Person("Alice", 30);
console.log(alice.greet()); // "Hello, I'm Alice"
console.log(alice.haveBirthday()); // 31// class Person { ... } is roughly equivalent to:
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.greet = function() {
return `Hello, I'm ${this.name}`;
};
Person.prototype.haveBirthday = function() {
this.age++;
return this.age;
};Person (function/class)
├─ prototype ──→ Person.prototype
│ ├─ constructor ──→ Person
│ ├─ greet()
│ └─ haveBirthday()
│
alice (instance)
├─ name: "Alice"
├─ age: 30
└─ [[Prototype]] ──→ Person.prototype
// Named class expression
const Person = class NamedPerson {
constructor(name) {
this.name = name;
}
};
// Anonymous class expression
const Animal = class {
constructor(name) {
this.name = name;
}
};
// Class passed as an argument
function createInstance(ClassConstructor, ...args) {
return new ClassConstructor(...args);
}
const person = createInstance(Person, "Alice");The constructor method is called automatically when a new instance is created with new. There can only be one constructor per class.
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
}
const rect = new Rectangle(10, 5);If you don't define a constructor, JavaScript provides a default empty one:
class Empty {
// Implicit: constructor() {}
}class Special {
constructor() {
this.value = 42;
// Normally, `this` is returned automatically
// But you can return a different object:
return { overridden: true };
}
}
const s = new Special();
console.log(s); // { overridden: true }Returning a primitive from the constructor is ignored —
thisis still returned.
Methods defined inside a class body are automatically added to the prototype.
class Calculator {
constructor(value = 0) {
this.value = value;
}
add(n) {
this.value += n;
return this; // Enables method chaining
}
subtract(n) {
this.value -= n;
return this;
}
multiply(n) {
this.value *= n;
return this;
}
getValue() {
return this.value;
}
}
const calc = new Calculator(10);
const result = calc.add(5).subtract(3).multiply(2).getValue();
console.log(result); // 24const methodName = "greet";
class Person {
constructor(name) {
this.name = name;
}
[methodName]() {
return `Hello, ${this.name}`;
}
["say" + "Bye"]() {
return `Goodbye, ${this.name}`;
}
}Static methods belong to the class itself, not to instances. They're utility functions that don't require an instance.
class MathUtils {
static add(a, b) {
return a + b;
}
static multiply(a, b) {
return a * b;
}
static PI = 3.14159; // Static property (ES2022)
}
console.log(MathUtils.add(2, 3)); // 5
console.log(MathUtils.multiply(4, 5)); // 20
console.log(MathUtils.PI); // 3.14159
// Cannot call on instance
const utils = new MathUtils();
// utils.add(2, 3); // TypeError: utils.add is not a functionclass User {
constructor(name, email) {
this.name = name;
this.email = email;
this.createdAt = new Date();
}
static fromJSON(json) {
const data = JSON.parse(json);
return new User(data.name, data.email);
}
static createGuest() {
return new User("Guest", "guest@example.com");
}
}
const user = User.fromJSON('{"name":"Alice","email":"alice@example.com"}');
const guest = User.createGuest();class Config {
static settings = {};
static {
// Runs once when class is loaded
this.settings.apiUrl = "https://api.example.com";
this.settings.timeout = 5000;
console.log("Config initialized");
}
}Classes support getter and setter methods that look like properties from the outside.
class Circle {
constructor(radius) {
this.radius = radius;
}
get diameter() {
return this.radius * 2;
}
set diameter(value) {
this.radius = value / 2;
}
get area() {
return Math.PI * this.radius ** 2;
}
}
const c = new Circle(5);
console.log(c.diameter); // 10 (getter — no parentheses!)
console.log(c.area); // 78.54...
c.diameter = 20; // setter
console.log(c.radius); // 10class Temperature {
#celsius; // Private field (see next section)
constructor(celsius) {
this.celsius = celsius;
}
get celsius() {
return this.#celsius;
}
set celsius(value) {
if (value < -273.15) {
throw new Error("Temperature below absolute zero is not possible");
}
this.#celsius = value;
}
get fahrenheit() {
return (this.#celsius * 9/5) + 32;
}
set fahrenheit(value) {
this.celsius = (value - 32) * 5/9;
}
}JavaScript now supports true privacy with the # prefix. Private members are not accessible from outside the class.
class BankAccount {
#balance; // Private field
#transactions; // Private field
static #accountCounter = 0; // Private static field
constructor(initialBalance) {
this.#balance = initialBalance;
this.#transactions = [];
BankAccount.#accountCounter++;
}
deposit(amount) {
if (amount <= 0) {
throw new Error("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: "withdrawal", amount });
return this.#balance;
}
getBalance() {
return this.#balance;
}
// Private method
#logTransaction(transaction) {
console.log("Transaction:", transaction);
}
// Private static method
static #getNextId() {
return BankAccount.#accountCounter + 1;
}
}
const account = new BankAccount(1000);
account.deposit(500);
console.log(account.getBalance()); // 1500
// console.log(account.#balance); // SyntaxError: Private field must be declared// Closure-based (pre-ES2022)
function createCounter() {
let count = 0; // Truly private via closure
return {
increment() { return ++count; },
decrement() { return --count; }
};
}
// Class with private fields
class Counter {
#count = 0;
increment() {
return ++this.#count;
}
decrement() {
return --this.#count;
}
}| Approach | Pros | Cons |
|---|---|---|
| Closure | Works in all JS versions | Separate instance functions |
#private |
Shared prototype methods | ES2022+ only |
You can declare public fields directly in the class body (ES2022):
class User {
// Public field with default value
role = "user";
// Public field initialized in constructor
createdAt;
constructor(name) {
this.name = name;
this.createdAt = new Date();
}
}
const u = new User("Alice");
console.log(u.role); // "user"
console.log(u.createdAt); // Date objectclass Button {
constructor(label) {
this.label = label;
}
// Regular method — loses `this` when passed as callback
handleClickRegular() {
console.log(this.label);
}
// Arrow function field — `this` is bound permanently
handleClick = () => {
console.log(this.label);
};
}
const btn = new Button("Submit");
// Regular method loses context
setTimeout(btn.handleClickRegular, 100); // undefined (or error in strict mode)
// Arrow function preserves context
setTimeout(btn.handleClick, 100); // "Submit"
⚠️ Arrow function fields create a new function per instance, increasing memory usage. Prefer binding in the constructor or using.bind()for performance-critical code.
Classes support single inheritance using the extends keyword.
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} makes a sound`;
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // Must call before using `this`
this.breed = breed;
}
speak() {
return `${this.name} barks`;
}
fetch() {
return `${this.name} is fetching`;
}
}
const rex = new Dog("Rex", "Labrador");
console.log(rex.speak()); // "Rex barks"
console.log(rex.fetch()); // "Rex is fetching"class Employee {
constructor(name, salary) {
this.name = name;
this.salary = salary;
}
getDetails() {
return `${this.name}: $${this.salary}`;
}
}
class Manager extends Employee {
constructor(name, salary, department) {
super(name, salary); // Call parent constructor
this.department = department;
}
getDetails() {
// Call parent method
return `${super.getDetails()} (Manager of ${this.department})`;
}
}We'll explore inheritance in much more detail in the Inheritance tutorial.
| Feature | Constructor Function | ES6 Class |
|---|---|---|
| Syntax | function Person() {} |
class Person {} |
| Methods | Person.prototype.greet = ... |
Defined in class body |
| Static | Person.staticMethod = ... |
static method() {} |
| Private | Closures or WeakMap | #private |
| Hoisting | Hoisted | Not hoisted (TDZ) |
| Callable | Person() works |
Person() throws TypeError |
| typeof | "function" |
"function" |
// Constructor function
function PersonOld(name) {
this.name = name;
}
PersonOld.prototype.greet = function() {
return `Hello, ${this.name}`;
};
// Equivalent class
class PersonNew {
constructor(name) {
this.name = name;
}
greet() {
return `Hello, ${this.name}`;
}
}class Person {
constructor(name) {
this.name = name;
}
}
// ❌ Classes throw when called without new
const p = Person("Alice"); // TypeError: Class constructor cannot be invoked without 'new'
// ✅ Always use new
const p2 = new Person("Alice");class Parent {
constructor(value) {
this.value = value;
}
}
class Child extends Parent {
constructor(value, extra) {
// ❌ Cannot use `this` before super()
this.extra = extra; // ReferenceError!
super(value);
}
}
// ✅ Call super() first
class Child extends Parent {
constructor(value, extra) {
super(value);
this.extra = extra;
}
}class Counter {
count = 0;
increment() {
this.count++;
}
}
const c1 = new Counter();
const c2 = new Counter();
console.log(c1.increment === c2.increment); // true (shared)
// But arrow function fields are per-instance
class Counter2 {
count = 0;
increment = () => { this.count++; };
}
const c3 = new Counter2();
const c4 = new Counter2();
console.log(c3.increment === c4.increment); // false (separate functions)class Secret {
#password;
constructor(password) {
this.#password = password;
}
}
const s = new Secret("12345");
// ❌ SyntaxError — private fields are truly private
// console.log(s.#password);
// ✅ Use a getter if needed
class Secret {
#password;
constructor(password) {
this.#password = password;
}
verify(guess) {
return guess === this.#password;
}
}// ❌ Classes are NOT hoisted
const p = new Person(); // ReferenceError
class Person {
constructor(name) {
this.name = name;
}
}
// ✅ Function declarations ARE hoisted
const p2 = new Person2(); // Works!
function Person2(name) {
this.name = name;
}Implement a Stack class with private fields:
class Stack {
// Use #items as private array
// push(item), pop(), peek(), isEmpty(), size()
}
const stack = new Stack();
stack.push(10);
stack.push(20);
console.log(stack.peek()); // 20
console.log(stack.pop()); // 20
console.log(stack.size()); // 1
// stack.#items should not be accessibleCreate a Shape class with static factory methods:
class Shape {
static createCircle(radius) {
return new Circle(radius);
}
static createRectangle(width, height) {
return new Rectangle(width, height);
}
}
class Circle extends Shape {
// area(), perimeter()
}
class Rectangle extends Shape {
// area(), perimeter()
}
const c = Shape.createCircle(5);
const r = Shape.createRectangle(4, 6);Create an immutable Point class where methods return new instances:
class Point {
constructor(x, y) {
// Should be immutable
}
move(dx, dy) {
// Return new Point, don't modify this
}
// Getters for x and y
}
const p1 = new Point(0, 0);
const p2 = p1.move(5, 10);
console.log(p1.x, p1.y); // 0, 0 (unchanged)
console.log(p2.x, p2.y); // 5, 10Implement a simple EventEmitter using classes:
class EventEmitter {
// on(event, listener) — subscribe
// off(event, listener) — unsubscribe
// emit(event, ...args) — trigger event
// once(event, listener) — subscribe for one trigger only
}
const emitter = new EventEmitter();
emitter.on("message", (msg) => console.log(msg));
emitter.emit("message", "Hello!"); // "Hello!"
emitter.emit("message", "Again!"); // "Again!"Implement a QueryBuilder class using method chaining:
class QueryBuilder {
// select(fields)
// from(table)
// where(condition)
// orderBy(field, direction)
// build() — returns the query string
}
const query = new QueryBuilder()
.select(["name", "email"])
.from("users")
.where("age > 18")
.orderBy("name", "asc")
.build();
console.log(query);
// "SELECT name, email FROM users WHERE age > 18 ORDER BY name asc"- ES6 classes are syntactic sugar over JavaScript's prototype system
- Classes are not hoisted — declare them before use
- The
constructoris called when usingnew - Instance methods are shared via the prototype (efficient)
- Static methods belong to the class, not instances
- Getters/setters look like properties but execute code
- Private fields/methods (
#name) provide true encapsulation - Public fields can have default values in the class body
- Arrow function fields auto-bind
thisbut create per-instance functions extendscreates inheritance;super()calls the parent constructor- Classes must be called with
new— calling them as functions throws - In derived classes,
super()must be called before accessingthis
Now that you understand classes:
- Inheritance — master extends, super, method overriding, and composition
- Prototypes — understand what happens under the hood
- OOP Concepts — review design principles with your class knowledge
Happy coding! 🚀