I still remember the first time a codebase went from “a few helper functions” to “a product.” The moment you add users, files, and workflows, you start repeating the same shapes of data and behavior. That’s where classes and objects matter. They give you a consistent way to model real things, keep behaviors close to the data they act on, and reduce the mental load of understanding a growing system. When I’m mentoring teams, I frame classes as the recipe and objects as the dishes you actually serve. Each dish is different, but it follows the same recipe.
In this post, I’ll walk you through how I think about classes and objects in modern JavaScript, how I use them in 2026-era projects, and where I avoid them. You’ll see constructors, instance methods, static members, getters and setters, private fields, and inheritance. I’ll also show you how classes map to the underlying prototype system, how objects can exist without classes at all, and how to decide between class-based and function-based designs. The goal is simple: help you build code that is easy to reason about, safe to change, and pleasant to extend.
Classes and Objects as Mental Models
When I teach OOP in JavaScript, I start with the idea of a “shape.” A class defines the shape: which properties exist and which methods can be called. An object is a single, concrete instance of that shape. If I’m building a shipping app, a class might be Shipment, and each object is a real shipment with its own tracking number, status, and history.
This matters because JavaScript is a dynamic language; you can add and remove properties at runtime. Classes add a layer of discipline: they set a clear, shared contract for data and behavior. You can still break the rules, but a class invites you not to. That’s valuable when multiple people contribute to the same codebase.
A helpful analogy I use: a class is a passport template, and each object is a real passport. The template defines which fields are valid (name, birthdate, nationality). Each passport is a filled-out instance of that template, unique but still following the same format.
In JavaScript, classes are syntactic sugar over prototypes. The language already supports objects and prototypes, and class gives you a cleaner way to express the same relationships. You don’t have to adopt classes to do OOP, but they make complex systems easier to read and maintain.
Building a Class That Feels Real
A class becomes useful when it captures behavior, not just data. I encourage you to define methods that operate on the object’s own state. The constructor is where you set that state.
Here’s a class that models a support ticket. It has required fields, a derived property, and a method that changes status. This is a complete, runnable example.
class SupportTicket {
static statuses = ["open", "pending", "resolved", "closed"]; // shared by all tickets
constructor({ id, title, customerEmail, createdAt = new Date() }) {
if (!id |
!title !customerEmail) {
throw new Error("id, title, and customerEmail are required");
}
this.id = id; // instance property
this.title = title;
this.customerEmail = customerEmail;
this.createdAt = createdAt;
this.status = "open";
}
// computed property via getter
get ageInHours() {
const ms = Date.now() - this.createdAt.getTime();
return Math.floor(ms / (1000 60 60));
}
setStatus(nextStatus) {
if (!SupportTicket.statuses.includes(nextStatus)) {
throw new Error(Invalid status: ${nextStatus});
}
this.status = nextStatus;
}
}
const ticket = new SupportTicket({
id: "TCK-1027",
title: "Cannot upload profile image",
customerEmail: "[email protected]"
});
console.log(ticket.status); // open
console.log(ticket.ageInHours); // 0 (or close)
ticket.setStatus("pending");
console.log(ticket.status); // pending
A few points I want you to notice:
- The constructor uses an object parameter, which scales better than positional parameters as the class grows.
staticmembers belong to the class, not each instance. That’s great for shared constants.- The getter provides a derived value without requiring a separate method call.
If you build classes like this, they behave like real domain objects and keep validation close to the data they protect.
Methods, Getters, Setters, and Encapsulation
A class becomes a safe place for logic when you use methods to guard state transitions. In my experience, this is where JavaScript classes really pay off.
Getters and setters are especially useful when you need to enforce rules while keeping the API clean. Here’s a BankAccount class that prevents direct access to its balance and validates changes.
class BankAccount {
#balance = 0; // private field
constructor({ accountNumber, ownerName }) {
this.accountNumber = accountNumber;
this.ownerName = ownerName;
}
get balance() {
return this.#balance;
}
deposit(amount) {
if (amount <= 0) throw new Error("Deposit must be positive");
this.#balance += amount;
}
withdraw(amount) {
if (amount <= 0) throw new Error("Withdrawal must be positive");
if (amount > this.#balance) throw new Error("Insufficient funds");
this.#balance -= amount;
}
}
const account = new BankAccount({
accountNumber: "AC-3099",
ownerName: "Kai Morgan"
});
account.deposit(250);
console.log(account.balance); // 250
account.withdraw(40);
console.log(account.balance); // 210
Private fields (#balance) are part of modern JavaScript and are supported in every current major runtime. I use them for data that shouldn’t be touched directly. They give you true encapsulation and force callers to use your public methods. This is a major difference from older patterns where “private” was only a naming convention.
Getters are best when a property feels like data, even though it’s computed. Setters are helpful when you want assignment syntax with validation. I don’t use setters for operations that are semantically actions (like withdraw), because I want those to read as verbs.
Objects Without Classes: When That’s Better
Not every object needs a class. JavaScript excels with lightweight object literals, and I still use them all the time, especially for configuration, data snapshots, and one-off structures.
Here’s a small example. Notice there is no class here, but the objects still carry meaning and structure.
const shippingLabels = [
{
orderId: "ORD-901",
recipient: "Aria Carter",
address: { city: "Austin", region: "TX", postal: "73301" },
weightLbs: 4.2
},
{
orderId: "ORD-902",
recipient: "Noah Singh",
address: { city: "Denver", region: "CO", postal: "80201" },
weightLbs: 2.7
}
];
console.log(shippingLabels[0].address.city); // Austin
I use object literals when:
- There is no shared behavior, only data.
- The data is a temporary snapshot (API response, parsed CSV, etc.).
- I want a simple, plain structure without methods.
You should still document the structure with JSDoc or TypeScript types, because runtime doesn’t enforce it. In 2026, most teams I work with either use TypeScript or run checkJs mode with JSDoc annotations to keep object shapes visible.
Object Property Access Patterns
You have two access patterns, and both are important:
- Dot notation:
order.recipient - Bracket notation:
order[dynamicKey]
I tell people to use bracket notation when property names are dynamic or not valid identifiers, and dot notation otherwise for clarity and editor support.
Classes vs Functions vs Factories: A Modern Comparison
JavaScript gives you multiple ways to build reusable shapes. Classes are common, but factory functions and plain objects are still valid. Here’s a simple comparison I use when deciding which route to take.
Traditional Use
I Use It When
—
—
Modeling entities with methods
I need shared behavior, identity, and lifecycle rules
Creating objects without new
I want to compose features or avoid inheritance
Configuration and data
I need quick, static dataHere’s a factory example that composes behaviors. It’s lightweight and test-friendly.
function createInventoryItem({ sku, name }) {
let quantity = 0; // private via closure
return {
sku,
name,
addStock(amount) {
if (amount <= 0) throw new Error("Amount must be positive");
quantity += amount;
},
removeStock(amount) {
if (amount <= 0) throw new Error("Amount must be positive");
if (amount > quantity) throw new Error("Not enough stock");
quantity -= amount;
},
getQuantity() {
return quantity;
}
};
}
const item = createInventoryItem({ sku: "SKU-101", name: "USB-C Cable" });
item.addStock(25);
console.log(item.getQuantity()); // 25
This pattern avoids new, avoids this, and uses closures for privacy. It’s a good choice when you want to build behavior by composition rather than inheritance. I pick classes when I want instances that are clearly the same “type,” like Order, User, or Invoice.
Inheritance and Composition: Pick the Right Tool
Inheritance can be a clean way to share behavior, but I keep it shallow. In JavaScript, deep inheritance trees become fragile, especially when multiple teams touch the code. I keep a rule of thumb: one level of inheritance is fine, two levels are suspect, and more than that should be rethought.
Here’s a simple inheritance chain for a logistics system:
class Package {
constructor({ trackingId, weightLbs }) {
this.trackingId = trackingId;
this.weightLbs = weightLbs;
}
get isHeavy() {
return this.weightLbs >= 50;
}
}
class InternationalPackage extends Package {
constructor({ trackingId, weightLbs, customsCode }) {
super({ trackingId, weightLbs });
this.customsCode = customsCode;
}
requiresCustomsForm() {
return Boolean(this.customsCode);
}
}
const box = new InternationalPackage({
trackingId: "PKG-77",
weightLbs: 42,
customsCode: "CNS-5541"
});
console.log(box.isHeavy); // false
console.log(box.requiresCustomsForm()); // true
If you find yourself stacking behavior, composition often wins. For example, a HasAuditTrail module can be shared across different classes without inheritance.
const HasAuditTrail = (Base) => class extends Base {
#events = [];
record(event) {
this.#events.push({ event, at: new Date() });
}
get events() {
return this.#events.slice();
}
};
class Order {
constructor({ id }) {
this.id = id;
}
}
class AuditedOrder extends HasAuditTrail(Order) {}
const order = new AuditedOrder({ id: "ORD-501" });
order.record("created");
console.log(order.events.length); // 1
This “mixin” pattern is a strong alternative to inheritance because it keeps types flexible and avoids tight coupling.
Prototypes Under the Hood (Why It Matters)
Even if you love classes, you should know that JavaScript uses prototypes internally. Every object has a prototype, and class syntax just wires that for you. Knowing this helps you debug and reason about behavior.
Here’s the same class example and its prototype behavior:
class Customer {
constructor(name) {
this.name = name;
}
greet() {
return Hello, ${this.name};
}
}
const customer = new Customer("Morgan");
console.log(customer.greet()); // Hello, Morgan
console.log(Object.getPrototypeOf(customer) === Customer.prototype); // true
console.log(Customer.prototype.greet === customer.greet); // true
The greet method lives on the prototype, not on each instance. That means it’s shared across all instances, which is memory-efficient and fast in modern engines.
Knowing this helps you avoid mistakes like defining methods inside the constructor (which creates a new function per instance). Use prototype methods or class methods for shared behavior.
Common Mistakes I See and How I Avoid Them
When I review JavaScript code, I often see the same class-related issues. Here’s my short list of “do this instead” guidance.
- Mistake: Overusing classes for data-only structures
If a type has no behavior, use a plain object with a clear type definition. Save classes for cases where methods add real value.
- Mistake: Storing derived values as writable properties
If totalPrice is derived from line items, make it a getter. That prevents stale data bugs.
- Mistake: Deep inheritance trees
Replace with composition or mixins. It makes refactoring safer and testing simpler.
- Mistake: Mutating objects in surprising ways
Keep methods focused and avoid hidden side effects. If a method changes multiple fields, document it or split it into smaller actions.
- Mistake: Ignoring validation
A constructor should guard the minimum required state. I treat it as the object’s contract.
When to Use Classes (and When I Don’t)
Here’s the decision rule I give to teams I mentor:
Use classes when:
- The object has behavior that should travel with the data.
- You need lifecycle rules (create, update, finalize).
- You want a stable public API across multiple files or services.
- You need to model real domain concepts (User, Invoice, Subscription).
Avoid classes when:
- You’re just passing data around.
- The shape is small and short-lived.
- You need high flexibility and frequent shape changes.
- You’re modeling configuration or environment values.
A useful analogy is a “business card” vs a “person.” A business card is data-only; a person has behavior. Classes are for the person, not the card.
Performance Considerations in Real Apps
For most apps, class-related performance is a non-issue. Modern JS engines are very good at optimizing class instances and prototype methods. That said, there are a few patterns I avoid:
- Per-instance methods: Methods defined inside the constructor create a new function per instance. If you create 10,000 objects, that’s 10,000 functions. Prefer prototype methods.
- Hot path getters: Getters that do heavy work can be expensive in hot loops. I use getters for light calculations and prefer explicit methods for heavier work.
- Large object graphs: Deeply nested objects can cause higher GC pressure. I keep object graphs shallow where possible.
In practice, if you’re seeing slowdowns, it’s usually from data processing or I/O, not from class usage. But it’s still worth writing methods that do clear, predictable work. As a rough guide, creating a few thousand class instances is typically quick (often under 10–15ms in modern engines), while creating hundreds of thousands can start to show measurable costs.
Modern Workflow Notes (2026)
In 2026, most JavaScript teams use TypeScript or JSDoc with checkJs to keep class APIs explicit. I recommend adding types even if your runtime is plain JS, because it documents class contracts for the whole team. It also makes refactoring safer when a class evolves.
I also see more teams pairing classes with “functional edges.” That means classes handle domain modeling, while functions handle I/O. For example, your Order class might live in domain/Order.js while API calls live in services/orderApi.js. This separation makes unit tests easier and keeps your domain model from depending on network or database details.
Deepening the Mental Model: Identity, Equality, and State
One subtle thing about objects and classes is identity. Two objects with the same properties are still different objects. That matters in a few places:
- Reference equality:
a === bis true only when they are the same object, not merely equivalent. - Caching and memoization: If you want to reuse results, you need stable object identity or a stable key.
- Collections: Objects as keys in
MaporWeakMaprely on identity, not value.
Here’s a tiny example that trips up newcomers:
const a = { id: 1 };
const b = { id: 1 };
console.log(a === b); // false
const map = new Map();
map.set(a, "first");
console.log(map.get(b)); // undefined
When classes are used for domain objects, that identity can be a strength. If you load a User instance and pass it around, all parts of your code are referring to the same entity. But it also means you need to be deliberate about cloning and immutability.
If you want value-based equality, you should build an explicit equals method (or use a hashing strategy). Example:
class User {
constructor({ id, email }) {
this.id = id;
this.email = email;
}
equals(other) {
return other instanceof User && other.id === this.id;
}
}
const u1 = new User({ id: "U-1", email: "[email protected]" });
const u2 = new User({ id: "U-1", email: "[email protected]" });
console.log(u1.equals(u2)); // true
This kind of method makes your intent explicit and keeps equality consistent across your app.
Constructors as Contracts: Guarding Invariants
When I say constructors are “contracts,” I mean they should enforce the minimum state required for the object to be valid. If an object can exist in an invalid state, the rest of your code becomes defensive and noisy.
Here’s a real-world example: an Invoice that should never exist without a customer and at least one line item. The constructor makes that explicit.
class Invoice {
constructor({ id, customerId, lineItems }) {
if (!id) throw new Error("Invoice id is required");
if (!customerId) throw new Error("customerId is required");
if (!Array.isArray(lineItems) || lineItems.length === 0) {
throw new Error("Invoice needs at least one line item");
}
this.id = id;
this.customerId = customerId;
this.lineItems = lineItems;
this.createdAt = new Date();
}
get totalCents() {
return this.lineItems.reduce((sum, li) => sum + li.priceCents * li.quantity, 0);
}
}
If you’ve worked in a codebase without these guards, you’ve seen the result: functions that check for missing fields everywhere, bugs that happen only in edge cases, and a ton of “just in case” branching. A strong constructor makes your objects reliable.
Factory Methods and Static Builders
Sometimes you need more than a single constructor. Static factory methods let you build instances from different inputs while keeping the class responsible for its own creation rules.
Consider an Event class you can build from a JSON payload or from individual arguments. Static factories keep that clean.
class Event {
constructor({ id, name, startsAt }) {
if (!id |
!name !startsAt) throw new Error("Missing required fields");
this.id = id;
this.name = name;
this.startsAt = new Date(startsAt);
}
static fromApi(payload) {
return new Event({
id: payload.event_id,
name: payload.title,
startsAt: payload.starts_at
});
}
static fromParts(id, name, startsAt) {
return new Event({ id, name, startsAt });
}
}
I like this approach because it centralizes creation logic and makes it easy to add new creation paths without polluting the constructor with conditionals.
Edge Cases: What Breaks and How to Handle It
When you start using classes in production, edge cases show up. Here are a few I see often and how I handle them.
1) JSON Serialization and Private Fields
JSON.stringify only includes enumerable properties on the object. Private fields are not enumerable, so they won’t show up in JSON output.
class Session {
#token;
constructor(token) {
this.#token = token;
this.createdAt = new Date();
}
}
const s = new Session("secret");
console.log(JSON.stringify(s)); // {"createdAt":"..."}
If you need private data to be serialized, you must define a custom toJSON method:
class Session {
#token;
constructor(token) {
this.#token = token;
this.createdAt = new Date();
}
toJSON() {
return {
token: this.#token,
createdAt: this.createdAt
};
}
}
2) Method Context (this) in Callbacks
Passing class methods as callbacks can lose the correct this context.
class Clock {
constructor() {
this.ticks = 0;
}
tick() {
this.ticks += 1;
}
}
const clock = new Clock();
setInterval(clock.tick, 1000); // this is undefined in strict mode
Fix it by binding:
setInterval(clock.tick.bind(clock), 1000);
Or by defining the method as a public field arrow function (which keeps this bound), though I use that sparingly because it creates a new function per instance:
class Clock {
ticks = 0;
tick = () => {
this.ticks += 1;
};
}
3) Inheritance with Constructors
If you override a constructor, you must call super() before using this in a subclass.
class Base {
constructor() {
this.createdAt = new Date();
}
}
class Derived extends Base {
constructor() {
super();
this.name = "Derived";
}
}
4) Copying Objects vs Cloning Instances
If you use object spread on a class instance, you lose the prototype methods.
class Note {
constructor(text) {
this.text = text;
}
shout() {
return this.text.toUpperCase();
}
}
const n = new Note("hello");
const copy = { ...n };
console.log(copy.shout); // undefined
If you need a copy, add a clone method that returns a new instance:
class Note {
constructor(text) {
this.text = text;
}
shout() {
return this.text.toUpperCase();
}
clone() {
return new Note(this.text);
}
}
Composition in Practice: Real-World Example
Here’s a more complete example that shows how I structure classes with composition in a practical scenario: a lightweight help desk system.
We’ll have:
- A
Ticketclass for the core domain object. - A
Notifiablemixin that adds notification behavior. - A
HasTagsmixin for flexible tagging.
const Notifiable = (Base) => class extends Base {
notify(message) {
// In real apps, this would send email or push notifications
this.lastNotification = { message, at: new Date() };
}
};
const HasTags = (Base) => class extends Base {
#tags = new Set();
addTag(tag) {
if (!tag) throw new Error("Tag required");
this.#tags.add(tag);
}
removeTag(tag) {
this.#tags.delete(tag);
}
get tags() {
return Array.from(this.#tags);
}
};
class Ticket {
constructor({ id, title }) {
if (!id || !title) throw new Error("id and title required");
this.id = id;
this.title = title;
this.status = "open";
}
close() {
this.status = "closed";
}
}
class AdvancedTicket extends HasTags(Notifiable(Ticket)) {}
const t = new AdvancedTicket({ id: "T-88", title: "Login issue" });
t.addTag("auth");
t.notify("We received your ticket");
t.close();
console.log(t.tags); // ["auth"]
console.log(t.lastNotification.message); // We received your ticket
This structure keeps each concern focused. You can mix HasTags into other classes without copying code or building a deep inheritance tree.
Objects as Records: Immutable Patterns
Sometimes the best “object” is an immutable record. I use immutable objects when I want predictable state changes, especially in UIs or event-driven systems.
Here’s a simple immutable pattern using object spread:
const order = {
id: "ORD-1",
status: "open",
totalCents: 1200
};
const updated = { ...order, status: "paid" };
console.log(order.status); // open
console.log(updated.status); // paid
With classes, you can still encourage immutability by returning new instances instead of mutating state. That can be a big win in systems where auditability or state history matters.
class Order {
constructor({ id, status, totalCents }) {
this.id = id;
this.status = status;
this.totalCents = totalCents;
}
markPaid() {
return new Order({
id: this.id,
status: "paid",
totalCents: this.totalCents
});
}
}
I don’t do this everywhere because it can be more verbose, but for domain objects that need traceability, it’s worth it.
Testing Classes the Right Way
Testing is where class design either pays off or becomes a tax. A few practices help a lot:
- Keep constructors minimal: Heavy I/O in constructors makes testing harder. I initialize state, not external dependencies.
- Test behavior, not internals: Use public methods and getters. Avoid tests that depend on private fields.
- Make side effects explicit: Methods that call APIs or perform I/O should be separate from pure domain logic, or injectable.
Here’s a tiny example showing how I structure a testable class with a dependency injected through the constructor:
class Emailer {
constructor({ transport }) {
this.transport = transport;
}
send(to, subject, body) {
return this.transport.send({ to, subject, body });
}
}
// In tests, use a fake transport
const fakeTransport = {
sent: [],
send(payload) {
this.sent.push(payload);
return true;
}
};
const emailer = new Emailer({ transport: fakeTransport });
emailer.send("[email protected]", "Hi", "Hello");
console.log(fakeTransport.sent.length); // 1
This is a small shift, but it makes your classes much easier to test and your production code more flexible.
The Prototype Chain in Everyday Debugging
Knowing about prototypes helps in debugging subtle issues. For instance, if you log an object and don’t see a method, it might be on the prototype, not on the instance. Use Object.getPrototypeOf to inspect.
If you ever need to check whether a method is coming from the instance or the prototype:
const hasOwn = Object.prototype.hasOwnProperty;
console.log(hasOwn.call(customer, "greet")); // false
console.log(hasOwn.call(Customer.prototype, "greet")); // true
This can help you detect unintended overrides or bugs where you set a property with the same name as a method.
Practical Scenario: Modeling an Order Lifecycle
Let’s build a more complete example that models a real business object with a lifecycle. This pattern comes up constantly in production code.
Goals:
- Enforce allowed states.
- Prevent illegal transitions.
- Keep the API readable.
class Order {
static statuses = ["draft", "submitted", "paid", "shipped", "canceled"];
constructor({ id, items }) {
if (!id) throw new Error("Order id required");
if (!Array.isArray(items) || items.length === 0) {
throw new Error("Order must have at least one item");
}
this.id = id;
this.items = items;
this.status = "draft";
}
submit() {
if (this.status !== "draft") {
throw new Error("Only draft orders can be submitted");
}
this.status = "submitted";
}
pay() {
if (this.status !== "submitted") {
throw new Error("Only submitted orders can be paid");
}
this.status = "paid";
}
ship() {
if (this.status !== "paid") {
throw new Error("Only paid orders can be shipped");
}
this.status = "shipped";
}
cancel() {
if (this.status === "shipped") {
throw new Error("Shipped orders cannot be canceled");
}
this.status = "canceled";
}
}
This is a good example of why classes are useful: you centralize rules and protect state transitions. Without a class, you’d be replicating these checks in multiple places.
Alternative Approach: Pure Functions + Plain Objects
To keep things balanced, here’s the same order lifecycle with a functional approach. This can be a good choice in highly functional codebases or in small services.
const OrderStatus = {
DRAFT: "draft",
SUBMITTED: "submitted",
PAID: "paid",
SHIPPED: "shipped",
CANCELED: "canceled"
};
function submit(order) {
if (order.status !== OrderStatus.DRAFT) throw new Error("Only draft orders can be submitted");
return { ...order, status: OrderStatus.SUBMITTED };
}
function pay(order) {
if (order.status !== OrderStatus.SUBMITTED) throw new Error("Only submitted orders can be paid");
return { ...order, status: OrderStatus.PAID };
}
This avoids this and can be easier to test, but you lose the grouping of data and behavior. I choose this approach when I want immutability and simple data flows (for example, in event-sourced systems).
Common Pitfalls and Their Fixes (Expanded)
Here are a few more issues that come up often in real codebases:
- Mistake: Using public fields for sensitive data
If you store API tokens or secrets on a public field, any code can read it. Use private fields or closures.
- Mistake: Mixing concerns inside a class
If your User class makes API calls and writes to the DOM, it’s doing too much. Split domain logic from side effects.
- Mistake: Large constructors with many parameters
This is a signal to use an options object and possibly split responsibilities into sub-objects.
- Mistake: Relying on
instanceofacross module boundaries
If multiple copies of the same class exist (monorepos or bundles), instanceof may fail. Prefer duck typing or a symbol tag.
A lightweight tag example:
const TYPE = Symbol("type");
class Receipt {
constructor() {
this[TYPE] = "Receipt";
}
}
function isReceipt(obj) {
return obj && obj[TYPE] === "Receipt";
}
More on Getters/Setters: When to Use Them
Getters and setters are powerful, but they can become confusing if overused. My rules:
- Use a getter when the value is derived and cheap.
- Use a setter when assignment feels natural (e.g.,
user.email = ...). - Avoid setters for operations with side effects (like sending email or writing to disk).
Here’s a balanced example:
class Temperature {
constructor(celsius) {
this._celsius = celsius;
}
get celsius() {
return this._celsius;
}
set celsius(value) {
if (value < -273.15) throw new Error("Below absolute zero");
this._celsius = value;
}
get fahrenheit() {
return (this._celsius * 9) / 5 + 32;
}
}
Notice the underscore field. I still use underscores for semi-private internal fields even with private fields available, mostly when I want the field to be serializable or subclass-friendly.
Static Members: Shared State vs Shared Constants
Static members are useful for shared constants and factories, but I avoid using them for shared mutable state unless I really mean “global.”
Good static usage:
class Status {
static OPEN = "open";
static CLOSED = "closed";
}
Risky static usage:
class Counter {
static value = 0;
static inc() { Counter.value += 1; }
}
This is essentially a global variable. It can be fine, but treat it as such and be explicit about ownership.
Object Freezing and Sealing
Sometimes you want to prevent mutation. JavaScript gives you Object.freeze and Object.seal. I use these sparingly, mostly for configuration objects or to enforce immutability at runtime.
const config = Object.freeze({
apiBase: "https://api.example.com",
timeoutMs: 5000
});
Frozen objects can’t have properties changed, added, or removed. It’s a runtime guard that can save you from accidental bugs in large systems.
A Practical Checklist for Choosing an Approach
When you’re not sure whether to use a class, run through this quick checklist:
- Do I need behavior tightly coupled to data? If yes, class.
- Will there be many instances with the same behavior? If yes, class.
- Is this mostly configuration or a transient snapshot? If yes, object literal.
- Do I want composition and privacy without
this? If yes, factory. - Do I need immutability and simple data flow? If yes, plain objects + functions.
If you’re on the fence, default to the simplest approach that keeps your code readable. You can always move to a class later if the domain grows.
Debugging and Tooling Tips
A few small habits make working with objects and classes smoother:
- Use
console.dir(obj)in Node or devtools to inspect prototypes. - Enable
checkJsor use TypeScript to keep shapes visible. - Prefer named classes and named methods for clearer stack traces.
- Avoid anonymous class expressions unless you’re intentionally hiding the name.
Example for better stack traces:
class PaymentProcessor {
charge() {
throw new Error("Charge failed");
}
}
Named classes give you better error messages and more readable logs.
Classes in the Context of Modern Frameworks
If you build web apps, you’ve likely seen frameworks that favor functions (hooks, composables) over classes. That doesn’t mean classes are obsolete. I still use them for domain modeling, services, and adapters, while letting UI be functional.
A helpful split I use:
- Classes: domain objects, service adapters, data validation
- Functions: UI glue, composable logic, event handlers
This split keeps concerns separate and reduces the pressure to force everything into one paradigm.
Putting It All Together: A Realistic Example
Let’s end with a realistic mini-system that ties multiple ideas together: a Subscription model.
Requirements:
- Validate required fields.
- Enforce state transitions.
- Keep price computation consistent.
- Support JSON serialization.
class Subscription {
static plans = {
BASIC: { id: "basic", priceCents: 1000 },
PRO: { id: "pro", priceCents: 3000 }
};
#status = "trial";
constructor({ id, userId, planId }) {
if (!id |
!userId !planId) throw new Error("Missing fields");
if (!Subscription.planById(planId)) throw new Error("Invalid plan");
this.id = id;
this.userId = userId;
this.planId = planId;
this.createdAt = new Date();
}
static planById(planId) {
return Object.values(Subscription.plans).find((p) => p.id === planId);
}
get status() {
return this.#status;
}
get priceCents() {
return Subscription.planById(this.planId).priceCents;
}
activate() {
if (this.#status !== "trial") throw new Error("Only trial can be activated");
this.#status = "active";
}
cancel() {
if (this.#status === "canceled") return;
this.#status = "canceled";
}
toJSON() {
return {
id: this.id,
userId: this.userId,
planId: this.planId,
status: this.#status,
priceCents: this.priceCents,
createdAt: this.createdAt
};
}
}
This is a small class, but it captures a lot: validation, state, computed values, and serialization. It’s the kind of model that makes a system easier to reason about, especially as it scales.
Final Thoughts
Classes and objects are not about being “object-oriented” for its own sake. They’re about managing complexity. If a class makes the code more expressive, more predictable, and easier to change, it’s worth using. If a class adds ceremony without real value, skip it and use a simpler structure.
In modern JavaScript, you have choices: classes, factories, plain objects, and functional modules. The best developers I know choose deliberately based on the problem in front of them. That’s the mindset I want you to adopt: not “classes everywhere,” but “the right tool for the job.”
If you keep your objects honest, your constructors strict, your methods focused, and your relationships shallow, your code will feel calm even as it grows. And that, in my experience, is the real payoff of understanding classes and objects in JavaScript.


