JavaScript Zero to Advanced #9: Classes, Prototypes & ES Modules
As programs grow, you need ways to organize code. Classes bundle data and the functions that work on that data. Modules split a program into files with clear boundaries. And underneath classes sits a mechanism that makes JavaScript unique: prototypes. This part covers all three.
Classes: the basics
A class is a blueprint for creating objects. The constructor runs when you create an instance with new, and methods are shared by every instance:
class Product {
constructor(name, price, stock = 0) {
this.name = name
this.price = price
this.stock = stock
}
isAvailable() {
return this.stock > 0
}
describe() {
return `${this.name} - Rp${this.price} (${this.isAvailable() ? "in stock" : "sold out"})`
}
}
const keyboard = new Product("Keyboard", 450000, 12)
const mouse = new Product("Mouse", 150000)
console.log(keyboard.describe())
console.log(mouse.describe())
console.log(keyboard instanceof Product)
console.log(typeof Product)
Output:
Keyboard - Rp450000 (in stock)
Mouse - Rp150000 (sold out)
true
function
Notice that typeof Product is "function". Classes are mostly a cleaner syntax on top of functions and prototypes, as we will see shortly.
Getters, setters, static members and private fields
Modern classes have a few extra features that help you design clean APIs:
- Getters/setters (
get/set) look like properties but run code. - Static members belong to the class itself, not to instances.
- Private fields start with
#and cannot be accessed from outside the class.
class BankAccount {
static #count = 0
#balance = 0
constructor(owner) {
this.owner = owner
BankAccount.#count++
}
get balance() {
return this.#balance
}
deposit(amount) {
if (amount <= 0) throw new Error("Amount must be positive")
this.#balance += amount
return this // allows chaining
}
static get totalAccounts() {
return BankAccount.#count
}
}
const budi = new BankAccount("Budi")
budi.deposit(50000).deposit(25000)
new BankAccount("Siti")
console.log(budi.balance)
try {
budi.balance = 999999 // there is no setter
} catch (error) {
console.log(error.constructor.name)
}
console.log(budi.balance)
console.log(BankAccount.totalAccounts)
console.log(Object.keys(budi))
try {
budi.deposit(-10)
} catch (error) {
console.log(error.message)
}
Output:
75000
TypeError
75000
2
[ 'owner' ]
Amount must be positive
The balance can only change through deposit, so no other code can accidentally set it to a wrong value. That is encapsulation. Private fields do not even show up in Object.keys. Assigning to a property that only has a getter throws a TypeError in strict mode (ES modules and class bodies always use it); in old non-strict scripts the assignment is silently ignored. Either way, the balance stays the same.
Inheritance with extends and super
A class can extend another class to reuse and specialize its behavior. Call super(...) in the constructor before using this, and use super.method() to call the parent's version of a method:
class User {
constructor(name, email) {
this.name = name
this.email = email
}
getPermissions() {
return ["read"]
}
toString() {
return `${this.name} <${this.email}>`
}
}
class Admin extends User {
constructor(name, email, level) {
super(name, email)
this.level = level
}
getPermissions() {
return [...super.getPermissions(), "write", "delete"]
}
}
const siti = new User("Siti", "siti@example.com")
const andi = new Admin("Andi", "andi@example.com", 2)
console.log(String(siti), siti.getPermissions())
console.log(String(andi), andi.getPermissions())
console.log(andi instanceof Admin, andi instanceof User, siti instanceof Admin)
Output:
Siti <siti@example.com> [ 'read' ]
Andi <andi@example.com> [ 'read', 'write', 'delete' ]
true true false
Use inheritance for real "is a" relationships (an Admin is a User). For "has a" relationships, prefer composition: store another object as a property instead of extending it. Deep inheritance trees quickly become hard to change.
The lost this problem
Methods depend on this, which is decided by how a function is called. If you pull a method off its object, for example to use it as a callback, this is lost:
class Timer {
constructor(label) {
this.label = label
}
report() {
return `Timer: ${this.label}`
}
}
const timer = new Timer("download")
const report = timer.report
try {
console.log(report())
} catch (error) {
console.log(error.constructor.name)
}
const bound = timer.report.bind(timer)
console.log(bound())
console.log([1].map(() => timer.report())[0])
Output:
TypeError
Timer: download
Timer: download
Fix it with bind, or wrap the call in an arrow function so it is called on the object. This is very common when passing methods to addEventListener or setTimeout.
Under the hood: prototypes
Every JavaScript object has a hidden link to another object called its prototype. When you read a property that the object does not have, JavaScript looks at the prototype, then the prototype's prototype, and so on. This is the prototype chain. Class methods live on the prototype, which is why all instances share them:
class Animal {
constructor(name) {
this.name = name
}
speak() {
return `${this.name} makes a sound`
}
}
const cat = new Animal("Kitty")
console.log(Object.getPrototypeOf(cat) === Animal.prototype)
console.log(Object.hasOwn(cat, "name"), Object.hasOwn(cat, "speak"))
console.log(Object.getPrototypeOf(Animal.prototype) === Object.prototype)
console.log(typeof cat.toString) // found further up the chain
Output:
true
true false
true
function
Before classes existed (ES2015), developers wrote the same thing with constructor functions and by adding methods to prototype directly. You will still see this in older code:
function Car(brand) {
this.brand = brand
}
Car.prototype.honk = function () {
return `${this.brand}: beep beep!`
}
const car = new Car("Toyota")
console.log(car.honk())
// Objects can also be linked directly
const base = { greet() { return `Hi, I am ${this.name}` } }
const person = Object.create(base)
person.name = "Dewi"
console.log(person.greet())
Output:
Toyota: beep beep!
Hi, I am Dewi
You rarely need to touch prototypes directly in modern code, but understanding them explains
instanceof, method sharing, and why arrays havemap: it lives onArray.prototype.
ES modules: import and export
Modules let each file have its own scope and explicitly choose what to share. There are two kinds of exports: named (many per file, imported with braces and the exact name) and default (one per file, imported with any name).
// math.js
export const PI = 3.14159
export function circleArea(radius) {
return PI * radius * radius
}
export default function formatRupiah(amount) {
return "Rp" + amount.toLocaleString("id-ID")
}
// app.js
import formatRupiah, { circleArea, PI as pi } from "./math.js"
import * as math from "./math.js"
console.log(pi)
console.log(circleArea(10).toFixed(2))
console.log(formatRupiah(1500000))
console.log(typeof math.circleArea)
To run this in Node.js, either name the files .mjs or add "type": "module" to package.json, then run node app.js:
Output:
3.14159
314.16
Rp1.500.000
function
In the browser, load the entry file with <script type="module" src="app.js"></script>. Module scripts are deferred automatically and run in strict mode. You can also load a module only when needed with dynamic import, which returns a Promise:
const { circleArea } = await import("./math.js")
console.log(circleArea(1))
Output:
3.14159
Older Node.js code uses CommonJS (require and module.exports). It still works, but new projects should use ES modules, which are also what bundlers and frameworks like Next.js expect.
Tips and common mistakes
- Forgetting
new. Calling a class withoutnewthrows TypeError: Class constructor ... cannot be invoked without 'new'. - Using
thisbeforesuper()in a subclass constructor throws a ReferenceError. - Losing
thiswhen passing methods as callbacks. Usebindor an arrow function. - Mixing up default and named imports.
import { formatRupiah } from "./math.js"fails if it was exported as default. - Missing file extensions. Browsers and Node.js ES modules need the full path, including
.js. - Overusing inheritance. Prefer small classes and composition; not everything needs to be a class.
Exercise
Build a small library system split into modules. In book.js, export a class Book with title, author and a private #available field, plus borrow() and giveBack() methods that throw an error when the action is not allowed. In ebook.js, export EBook extends Book whose borrow() always succeeds. In library.js, export a default class Library that stores books and has a getter availableCount. Import everything in main.js and test it.
Summary
- Classes define a constructor and shared methods; create instances with
new. - Getters, setters,
staticand#privatefields help you build safe, clear APIs. extendsandsuperenable inheritance; watch out for losingthis.- Classes are built on prototypes: properties are looked up along the prototype chain.
- ES modules use
export/import(named and default) to split code across files.
In the final part, Part 10, we go deep: the event loop, robust error handling, debounce and throttle, generators and memoization.
Related articles
PostgreSQL Zero to Advanced #8: Indexes & Performance
Read query plans with EXPLAIN ANALYZE and speed up a 500,000-row table with B-tree, composite, partial and GIN indexes.
PHP Zero to Advanced #8: Composer, Namespaces, PSR-4 Autoloading & Exceptions
Structure real PHP projects: organize code with namespaces, manage dependencies with Composer, autoload classes with PSR-4, and handle errors cleanly with exceptions.
Ruby on Rails Zero to Advanced #8: Forms, Strong Parameters & Full CRUD
Build a complete CRUD feature by hand with form_with and strong parameters, then compare it with what rails generate scaffold gives you for free.