Class Basic Syntax in JavaScript
JavaScript classes, introduced in ES2015, provide a cleaner and more intuitive syntax for creating objects and implementing inheritance. Despite their familiar appearance to developers coming from Java, C#, or Python, JavaScript classes are not a new object model. They are syntactic sugar over the existing prototype-based inheritance system.
Understanding what classes really do behind the scenes is crucial. This guide takes you through the class keyword, constructors, methods, and class fields, while constantly showing you what JavaScript actually creates under the hood. By the end, you will understand both the clean surface syntax and the prototype machinery that powers it.
The class Keyword: Syntactic Sugar Over Prototypes
Before classes, creating objects with shared methods required constructor functions and manual prototype assignment:
// Pre-class pattern (constructor function + prototype)
function User(name, age) {
this.name = name;
this.age = age;
}
User.prototype.greet = function() {
console.log(`Hi, I'm ${this.name}, age ${this.age}`);
};
User.prototype.isAdult = function() {
return this.age >= 18;
};
const alice = new User("Alice", 30);
alice.greet(); // "Hi, I'm Alice, age 30"
alice.isAdult(); // true
The class syntax does exactly the same thing but in a much cleaner way:
// Class syntax (does the same thing)
class User {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() {
console.log(`Hi, I'm ${this.name}, age ${this.age}`);
}
isAdult() {
return this.age >= 18;
}
}
const alice = new User("Alice", 30);
alice.greet(); // "Hi, I'm Alice, age 30"
alice.isAdult(); // true
Both versions produce identical objects with identical prototype chains. The class syntax is easier to read, easier to write, and less error-prone, but it does not introduce a new inheritance model.
Proving They Are the Same
class User {
constructor(name) {
this.name = name;
}
greet() {
console.log(`Hi, I'm ${this.name}`);
}
}
// What "class" creates is just a function
console.log(typeof User); // "function"
// The constructor IS the function itself
console.log(User === User.prototype.constructor); // true
// Methods live on the prototype
console.log(User.prototype.greet); // ƒ greet() { ... }
// Instances use the prototype chain
const alice = new User("Alice");
console.log(Object.getPrototypeOf(alice) === User.prototype); // true
console.log(alice.hasOwnProperty("name")); // true (own property)
console.log(alice.hasOwnProperty("greet")); // false (on prototype)
The constructor Method
The constructor is a special method that runs automatically when you create a new instance with new. It initializes the object's properties.
Basic Constructor
class Product {
constructor(name, price) {
this.name = name;
this.price = price;
this.inStock = true; // Default value
}
}
const laptop = new Product("Laptop", 999);
console.log(laptop.name); // "Laptop"
console.log(laptop.price); // 999
console.log(laptop.inStock); // true
Constructor with Validation
class Temperature {
constructor(celsius) {
if (typeof celsius !== "number" || isNaN(celsius)) {
throw new TypeError("Temperature must be a valid number");
}
if (celsius < -273.15) {
throw new RangeError("Temperature cannot be below absolute zero (-273.15°C)");
}
this.celsius = celsius;
}
toFahrenheit() {
return this.celsius * 9/5 + 32;
}
toString() {
return `${this.celsius}°C`;
}
}
const boiling = new Temperature(100);
console.log(boiling.toFahrenheit()); // 212
console.log(`${boiling}`); // "100°C"
// const invalid = new Temperature("hot");
// TypeError: Temperature must be a valid number
// const impossible = new Temperature(-300);
// RangeError: Temperature cannot be below absolute zero
Default Constructor
If you do not define a constructor, JavaScript provides an empty one automatically:
class Empty {
// No constructor defined
greet() {
return "Hello!";
}
}
// Equivalent to:
class EmptyExplicit {
constructor() {
// Empty: does nothing
}
greet() {
return "Hello!";
}
}
const obj = new Empty();
console.log(obj.greet()); // "Hello!"
constructor Must Be Called with new
Unlike constructor functions, which can accidentally be called without new, classes enforce the use of new:
class User {
constructor(name) {
this.name = name;
}
}
const alice = new User("Alice"); // Works fine
// const bob = User("Bob");
// TypeError: Class constructor User cannot be invoked without 'new'
This is a significant safety improvement over constructor functions, which silently fail (or pollute the global scope) when called without new.
Only One Constructor Allowed
A class can have at most one constructor method. Defining multiple constructors is a SyntaxError:
// WRONG: SyntaxError
// class Bad {
// constructor(a) {}
// constructor(a, b) {}
// }
If you need different ways to create instances, use static factory methods instead:
class Color {
constructor(r, g, b) {
this.r = r;
this.g = g;
this.b = b;
}
// Factory methods for different creation patterns
static fromHex(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return new Color(r, g, b);
}
static fromHSL(h, s, l) {
// Convert HSL to RGB (simplified)
// ...
return new Color(r, g, b);
}
toString() {
return `rgb(${this.r}, ${this.g}, ${this.b})`;
}
}
const red = new Color(255, 0, 0);
const blue = Color.fromHex("#0000FF");
console.log(`${red}`); // "rgb(255, 0, 0)"
console.log(`${blue}`); // "rgb(0, 0, 255)"
Class Methods
Methods defined inside a class body are placed on the class's prototype. They are shared by all instances, just like methods added to Constructor.prototype in the pre-class pattern.
Defining Methods
class Calculator {
constructor(initialValue = 0) {
this.value = initialValue;
}
add(n) {
this.value += n;
return this; // Enable method chaining
}
subtract(n) {
this.value -= n;
return this;
}
multiply(n) {
this.value *= n;
return this;
}
reset() {
this.value = 0;
return this;
}
result() {
return this.value;
}
}
const calc = new Calculator(10);
const result = calc.add(5).multiply(2).subtract(3).result();
console.log(result); // 27
Methods Are Non-Enumerable
One of the differences between classes and manually setting up prototypes is that class methods are automatically non-enumerable. They do not appear in for...in loops:
class User {
constructor(name) {
this.name = name;
}
greet() {
return `Hi, I'm ${this.name}`;
}
}
const alice = new User("Alice");
// for...in does NOT show class methods
for (const key in alice) {
console.log(key); // Only "name" ("greet" is not listed)
}
// Compare with manual prototype setup:
function UserOld(name) {
this.name = name;
}
UserOld.prototype.greet = function() {
return `Hi, I'm ${this.name}`;
};
const bob = new UserOld("Bob");
for (const key in bob) {
console.log(key); // "name" AND "greet" (both show up!)
}
This is because class uses Object.defineProperty under the hood to set methods as enumerable: false.
No Commas Between Methods
Class methods are separated by their definitions alone. There are no commas or semicolons between them:
class Example {
method1() {
return 1;
}
// No comma here!
method2() {
return 2;
}
// No comma here!
method3() {
return 3;
}
}
This differs from object literals, where properties must be separated by commas. This is a common mistake when switching between objects and classes.
What class Really Creates (Behind the Scenes)
When JavaScript encounters a class declaration, here is what actually happens:
class User {
constructor(name) {
this.name = name;
}
greet() {
console.log(`Hi, I'm ${this.name}`);
}
isAdmin() {
return false;
}
}
The engine does the following:
Step 1: Create a function named User. The function body is taken from the constructor method.
Step 2: Store all class methods on User.prototype with enumerable: false.
The result is roughly equivalent to:
// What the engine creates (simplified)
function User(name) {
// Enforced: must be called with "new"
if (!new.target) {
throw new TypeError("Class constructor User cannot be invoked without 'new'");
}
this.name = name;
}
Object.defineProperty(User.prototype, "greet", {
value: function() {
console.log(`Hi, I'm ${this.name}`);
},
enumerable: false,
configurable: true,
writable: true
});
Object.defineProperty(User.prototype, "isAdmin", {
value: function() {
return false;
},
enumerable: false,
configurable: true,
writable: true
});
Key Differences from Plain Constructor Functions
Even though classes are "sugar," there are real differences that you cannot replicate with just a constructor function:
class MyClass {
constructor() {}
method() {}
}
// 1. typeof is "function" (same as constructor functions)
console.log(typeof MyClass); // "function"
// 2. Classes MUST be called with "new"
// MyClass(); // TypeError
// 3. Class methods are non-enumerable
console.log(Object.keys(MyClass.prototype)); // [] (empty!)
// 4. Class body runs in strict mode (always)
// 5. Classes are NOT hoisted (even though they are functions)
// 6. The string representation is different
console.log(MyClass.toString());
// "class MyClass { constructor() {} method() {} }"
Visualizing the Prototype Chain
MyClass (function)
├── prototype ──→ MyClass.prototype (object)
│ ├── constructor ──→ MyClass (circular reference)
│ ├── method ──→ ƒ method()
│ └── [[Prototype]] ──→ Object.prototype
└── [[Prototype]] ──→ Function.prototype
instance = new MyClass()
├── (own properties from constructor)
└── [[Prototype]] ──→ MyClass.prototype
├── method()
└── [[Prototype]] ──→ Object.prototype
├── toString()
├── hasOwnProperty()
└── ...
Class Expressions (Named and Anonymous)
Just like functions, classes can be defined as expressions. This means they can be assigned to variables, passed as arguments, or returned from functions.
Anonymous Class Expression
const User = class {
constructor(name) {
this.name = name;
}
greet() {
return `Hi, I'm ${this.name}`;
}
};
const alice = new User("Alice");
console.log(alice.greet()); // "Hi, I'm Alice"
console.log(User.name); // "User" (inferred from variable)
Named Class Expression
A named class expression has a name that is only accessible inside the class body (similar to Named Function Expressions):
const User = class MyUser {
constructor(name) {
this.name = name;
}
clone() {
// "MyUser" is accessible inside the class
return new MyUser(this.name);
}
};
const alice = new User("Alice");
const aliceClone = alice.clone();
console.log(alice.name); // "Alice"
console.log(aliceClone.name); // "Alice"
console.log(alice === aliceClone); // false (different objects)
console.log(User.name); // "MyUser" (the explicit name takes priority)
// console.log(MyUser); // ReferenceError: MyUser is not defined (outside)
Dynamic Class Creation
Class expressions enable powerful patterns like dynamic class creation:
function createClass(greeting) {
return class {
constructor(name) {
this.name = name;
}
greet() {
return `${greeting}, ${this.name}!`;
}
};
}
const FormalUser = createClass("Good day");
const CasualUser = createClass("Hey");
const alice = new FormalUser("Alice");
const bob = new CasualUser("Bob");
console.log(alice.greet()); // "Good day, Alice!"
console.log(bob.greet()); // "Hey, Bob!"
Immediately Used Class Expression
You can even create and instantiate a class in one expression:
const config = new (class {
constructor() {
this.apiUrl = "https://api.example.com";
this.timeout = 5000;
this.retries = 3;
}
get(key) {
return this[key];
}
})();
console.log(config.get("apiUrl")); // "https://api.example.com"
console.log(config.timeout); // 5000
This is uncommon in practice, but it demonstrates the flexibility of class expressions.
Classes Are Not Hoisted (Unlike Function Declarations)
Function declarations are hoisted, meaning you can call them before their definition in the code. Classes are not hoisted in the same way. Technically, the class name is known to the engine (it exists in the scope), but it is in the Temporal Dead Zone (TDZ) until the class declaration is reached.
Function Declarations Are Hoisted
// Works! Function declarations are fully hoisted
const alice = new PersonFn("Alice");
console.log(alice.name); // "Alice"
function PersonFn(name) {
this.name = name;
}
Class Declarations Are NOT Hoisted
// FAILS! Class is in the TDZ
// const bob = new PersonClass("Bob");
// ReferenceError: Cannot access 'PersonClass' before initialization
class PersonClass {
constructor(name) {
this.name = name;
}
}
// Works after the declaration
const bob = new PersonClass("Bob");
console.log(bob.name); // "Bob"
Why Classes Are Not Hoisted
Classes can have extends clauses that reference other expressions. The parent class must be evaluated before the child class can be defined:
// The parent class must exist before the child class is created
class Animal {
constructor(name) {
this.name = name;
}
}
class Dog extends Animal {
bark() {
return `${this.name} says woof!`;
}
}
If classes were hoisted like function declarations, Dog might be created before Animal exists, which would break the extends clause. The TDZ ensures classes are created in the correct order.
Practical Implication
Always define your classes before you use them. This is usually natural because most codebases define classes at the top of a file and use them below, or import them from other modules:
// Define first
class UserService {
constructor(apiUrl) {
this.apiUrl = apiUrl;
}
async getUser(id) {
const response = await fetch(`${this.apiUrl}/users/${id}`);
return response.json();
}
}
// Use after definition
const service = new UserService("https://api.example.com");
Classes Always Run in Strict Mode
All code inside a class body automatically runs in strict mode, regardless of whether the surrounding code is in strict mode. You do not need to add "use strict". It is implicit and cannot be turned off.
What This Means in Practice
class Example {
method() {
// This is ALWAYS strict mode, even without "use strict"
// 1. Assigning to undeclared variables throws
// x = 10; // ReferenceError: x is not defined
// 2. "this" is undefined in standalone calls (not the global object)
// setTimeout(function() { console.log(this); }, 0); // undefined
// 3. Duplicate parameter names are not allowed
// method(a, a) {} // SyntaxError
// 4. Octal literals are not allowed
// const n = 010; // SyntaxError
// 5. Deleting plain variables is not allowed
// let y = 1; delete y; // SyntaxError
}
}
this in Detached Methods
Because class methods run in strict mode, this is undefined when a method is called without an object (not window):
class User {
constructor(name) {
this.name = name;
}
greet() {
console.log(`Hi, I'm ${this.name}`);
}
}
const alice = new User("Alice");
alice.greet(); // "Hi, I'm Alice" (called on object, this = alice)
const greetFn = alice.greet;
// greetFn();
// TypeError: Cannot read properties of undefined (reading 'name')
// Because in strict mode, "this" is undefined for standalone calls
This is actually helpful. It surfaces bugs immediately instead of silently using window as this.
Computed Method Names
Just like computed property names in objects, you can use computed method names in classes by wrapping the method name in square brackets:
const methodName = "greet";
class User {
constructor(name) {
this.name = name;
}
[methodName]() {
return `Hello, I'm ${this.name}`;
}
}
const alice = new User("Alice");
console.log(alice.greet()); // "Hello, I'm Alice"
Using Symbols as Method Names
Computed method names are particularly useful with Symbols, allowing you to define methods that do not conflict with any string-named properties:
const validate = Symbol("validate");
const serialize = Symbol("serialize");
class User {
constructor(name, email) {
this.name = name;
this.email = email;
if (!this[validate]()) {
throw new Error("Invalid user data");
}
}
[validate]() {
return this.name.length > 0 && this.email.includes("@");
}
[serialize]() {
return JSON.stringify({ name: this.name, email: this.email });
}
// Well-known symbol: customize string conversion
[Symbol.toPrimitive](hint) {
if (hint === "string") return this.name;
return null;
}
}
const alice = new User("Alice", "alice@example.com");
console.log(`${alice}`); // "Alice" (Symbol.toPrimitive at work)
console.log(alice[serialize]()); // '{"name":"Alice","email":"alice@example.com"}'
Dynamic Method Names from Expressions
const prefix = "get";
class Config {
constructor(data) {
this.data = data;
}
[`${prefix}Value`](key) {
return this.data[key];
}
[`${prefix}All`]() {
return { ...this.data };
}
}
const config = new Config({ host: "localhost", port: 3000 });
console.log(config.getValue("host")); // "localhost"
console.log(config.getAll()); // { host: "localhost", port: 3000 }
Class Fields (Public Instance Properties)
Class fields (also called class properties) allow you to declare instance properties directly in the class body, outside of the constructor. They were introduced in a later specification and are now fully supported in all modern environments.
Basic Class Fields
class User {
// Class fields: declared directly in the class body
role = "user";
isActive = true;
loginCount = 0;
constructor(name) {
this.name = name;
}
login() {
this.loginCount++;
console.log(`${this.name} logged in (${this.loginCount} times)`);
}
}
const alice = new User("Alice");
console.log(alice.role); // "user"
console.log(alice.isActive); // true
console.log(alice.loginCount); // 0
alice.login(); // "Alice logged in (1 times)"
alice.login(); // "Alice logged in (2 times)"
Class Fields Are Instance Properties, Not Prototype Properties
This is a critical distinction. Class fields are set on each instance, not on the prototype:
class Counter {
count = 0;
increment() {
this.count++;
}
}
const a = new Counter();
const b = new Counter();
a.increment();
a.increment();
console.log(a.count); // 2
console.log(b.count); // 0 (each instance has its own "count")
// Verify: count is an OWN property, not on the prototype
console.log(a.hasOwnProperty("count")); // true
console.log(Counter.prototype.hasOwnProperty("count")); // false
Compare with methods, which are on the prototype:
console.log(a.hasOwnProperty("increment")); // false
console.log(Counter.prototype.hasOwnProperty("increment")); // true
Class Fields with Complex Values
class TodoList {
items = [];
createdAt = new Date();
id = Math.random().toString(36).slice(2, 10);
add(text) {
this.items.push({ text, done: false, id: this.items.length });
}
toggle(id) {
const item = this.items.find(i => i.id === id);
if (item) item.done = !item.done;
}
}
const list1 = new TodoList();
const list2 = new TodoList();
list1.add("Learn JavaScript");
console.log(list1.items.length); // 1
console.log(list2.items.length); // 0 (separate array for each instance)
console.log(list1.id !== list2.id); // true (unique IDs)
Each instance gets a fresh copy of the field value. The items = [] creates a new array for each instance, not a shared array.
Arrow Function Class Fields for Auto-Binding
One of the most popular uses of class fields is creating auto-bound methods using arrow functions:
class Button {
constructor(label) {
this.label = label;
}
// Arrow function class field: "this" is always the instance
handleClick = () => {
console.log(`${this.label} clicked`);
};
// Regular method: "this" depends on how it's called
handleHover() {
console.log(`${this.label} hovered`);
}
}
const btn = new Button("Submit");
// Arrow field: works even when detached
const clickHandler = btn.handleClick;
clickHandler(); // "Submit clicked" ✓
// Regular method: breaks when detached
const hoverHandler = btn.handleHover;
// hoverHandler(); // TypeError: Cannot read properties of undefined
However, remember the trade-off: arrow function fields create a new function per instance, unlike prototype methods which are shared:
const btn1 = new Button("A");
const btn2 = new Button("B");
// Arrow field: different function for each instance
console.log(btn1.handleClick === btn2.handleClick); // false
// Regular method: same function (shared via prototype)
console.log(btn1.handleHover === btn2.handleHover); // true
Fields Are Evaluated in Declaration Order
Class fields are evaluated in the order they appear, and they can reference earlier fields:
class Config {
host = "localhost";
port = 3000;
baseUrl = `http://${this.host}:${this.port}`;
constructor() {
console.log(this.baseUrl); // "http://localhost:3000"
}
}
const config = new Config();
Fields are set before the constructor body runs, so the constructor can use them:
class Example {
value = 10;
constructor() {
// "value" is already 10 here
console.log(this.value); // 10
this.value = 20;
}
}
const ex = new Example();
console.log(ex.value); // 20 (constructor overwrote the field)
Class fields are syntactic sugar for property assignments at the beginning of the constructor. Writing value = 10 in the class body is roughly equivalent to writing this.value = 10 as the first line of the constructor.
Fields vs. Constructor Properties
Both approaches produce the same result, but they serve different purposes:
class User {
// Class fields: for default values and properties that don't depend on constructor arguments
role = "user";
isActive = true;
loginHistory = [];
// Constructor: for properties that depend on arguments
constructor(name, email) {
this.name = name;
this.email = email;
}
}
Use class fields for:
- Default values
- Properties initialized with fixed or computed values
- Arrow function methods that need auto-binding
Use constructor parameters for:
- Properties that depend on arguments passed when creating the instance
Summary
| Concept | Key Takeaway |
|---|---|
class keyword | Syntactic sugar over constructor functions and prototypes |
constructor | Special method that initializes instances; runs on new |
| Class methods | Placed on the prototype; shared by all instances; non-enumerable |
| Behind the scenes | Creates a function (constructor) with methods on its prototype |
| Class expressions | Classes can be assigned to variables, passed as arguments, returned from functions |
| No hoisting | Classes are in the Temporal Dead Zone until their declaration; define before use |
| Strict mode | Class body always runs in strict mode, automatically |
| Computed names | Method names can be dynamic expressions in [], including Symbols |
| Class fields | Instance properties declared in the class body; set on each instance, not the prototype |
| Arrow fields | Arrow function class fields auto-bind this but create a new function per instance |
Classes in JavaScript provide a clean, standardized way to define object blueprints. They make code more readable and structured compared to the constructor function pattern, while providing real benefits like enforced new usage, automatic strict mode, and non-enumerable methods. Understanding that classes are built on prototypes, not replacing them, gives you the full picture of how JavaScript object creation works at every level.