Why classes matter, even though they're syntax sugar
You've written a class, instantiated it, called methods on it, and everything worked. But have you ever wondered what's actually happening under the hood? Classes are syntax sugar: a hands-on guide for engineers reveals that JavaScript classes are nothing more than a convenient wrapper around the prototype-based system that's been powering the language for decades. Understanding this distinction isn't academic—it's the difference between debugging a mysterious this binding issue in five minutes versus five hours.
In production systems we've built for e-commerce platforms and SaaS applications, developers who understand that classes compile down to prototypes write cleaner code, debug faster, and make smarter architectural decisions. Let's dig into what that actually means.
Methods live on the prototype
When you define a class with methods, those methods don't live on each instance. They live on the prototype object that all instances share. This is crucial for memory efficiency and is the fundamental reason the prototype pattern exists.
Here's a typical class:
class User {
constructor(name: string) {
this.name = name;
}
greet(): string {
return `Hello, I'm ${this.name}`;
}
}
const alice = new User('Alice');
const bob = new User('Bob');
When you call alice.greet(), JavaScript doesn't find greet on the alice instance itself. It walks the prototype chain. It checks alice's own properties, doesn't find greet, then checks User.prototype, and finds it there. The same greet function is reused by both alice and bob.
You can verify this:
console.log(alice.hasOwnProperty('greet')); // false
console.log(User.prototype.hasOwnProperty('greet')); // true
console.log(alice.greet === bob.greet); // true — same function reference
This matters in production because it means memory usage scales with the number of instances, not with the number of instances times the number of methods. On a SaaS platform with millions of objects in memory, this distinction has real performance implications.
Compare this to properties defined in the constructor:
class User {
name: string;
private createdAt: Date;
constructor(name: string) {
this.name = name;
this.createdAt = new Date();
}
greet(): string {
return `Hello, I'm ${this.name}`;
}
}
Both name and createdAt live on each instance. They must, because their values differ per instance. Methods live on the prototype because their implementation is identical across all instances.
Instances share methods
Because methods live on the prototype, every instance shares the same method references. This has both advantages and gotchas worth understanding.
The advantage is obvious: memory efficiency and performance. A single greet function serves a million users. The gotcha emerges when you pass methods around or use them as callbacks.
Consider a real-world scenario from a ride-hailing platform we built:
class RideRequest {
id: string;
status: 'pending' | 'accepted' | 'completed' = 'pending';
constructor(id: string) {
this.id = id;
}
accept(): void {
this.status = 'accepted';
console.log(`Ride ${this.id} accepted`);
}
// This works fine when called directly:
// rideRequest.accept();
}
const request = new RideRequest('RIDE-123');
// But pass it as a callback...
setTimeout(request.accept, 1000);
// Error or unexpected behavior: 'this' is undefined or global
When you pass request.accept to setTimeout, you're passing a function reference without its context. When that function runs, this doesn't refer to the request object anymore.
The traditional solution was to bind it:
setTimeout(request.accept.bind(request), 1000);
Or use an arrow function in the constructor, which creates a new function per instance (bypassing the prototype optimization, but ensuring correct this):
class RideRequest {
id: string;
status: 'pending' | 'accepted' | 'completed' = 'pending';
constructor(id: string) {
this.id = id;
// Arrow function: creates a new function per instance
this.accept = () => {
this.status = 'accepted';
console.log(`Ride ${this.id} accepted`);
};
}
}
In modern TypeScript with strict type checking, this pattern is clearer because the type system knows this is bound. However, you're trading the memory efficiency of shared methods for the convenience of always-correct this. On systems with millions of instances, that trade-off matters.
typeof a class is 'function'
Here's something that surprises developers new to JavaScript's prototype model: typeof MyClass returns 'function', not 'class' or 'object'.
class User {
constructor(name: string) {
this.name = name;
}
}
console.log(typeof User); // 'function'
That's because classes are syntax sugar for constructor functions. The class syntax desugars to something like this:
function User(name: string) {
this.name = name;
}
User.prototype.greet = function() {
return `Hello, I'm ${this.name}`;
};
The class declaration creates a constructor function. When you call new User('Alice'), JavaScript:
- Creates a new empty object
- Sets that object's internal
[[Prototype]]toUser.prototype - Calls the constructor function with
thisbound to that new object - Returns the new object (unless the constructor explicitly returns something else)
This is why instanceof works as it does:
const alice = new User('Alice');
console.log(alice instanceof User); // true
console.log(alice instanceof Object); // true
JavaScript checks whether User.prototype appears anywhere in alice's prototype chain. Understanding that classes are functions helps explain inheritance, static methods, and why you can't call a class without new (well, you can, but it fails or behaves unexpectedly).
Understanding this helps debugging
When you're debugging a production issue—say, a method that doesn't exist on an object that should have it—understanding the prototype chain saves hours. We've debugged issues in trading platforms and CRM systems where the root cause was a misunderstanding of how methods attach to instances versus prototypes.
A concrete example: you serialize an object to JSON, transmit it over the network, parse it back, and suddenly methods are missing.
class Order {
id: string;
total: number;
constructor(id: string, total: number) {
this.id = id;
this.total = total;
}
calculateTax(): number {
return this.total * 0.1;
}
}
const order = new Order('ORD-1', 100);
console.log(order.calculateTax()); // 10 — works
const json = JSON.stringify(order);
const restored = JSON.parse(json);
console.log(restored.calculateTax()); // Error: not a function
Why? JSON.stringify only serializes own properties on the instance, not the prototype. When you parse the JSON, you get a plain object, not an Order instance. It has id and total, but no prototype chain, so no calculateTax method.
The fix is to understand the distinction and handle it intentionally:
const json = JSON.stringify(order);
const restored = Object.assign(new Order('', 0), JSON.parse(json));
console.log(restored.calculateTax()); // Works — now it's a real Order instance
Or use a reviver function to properly reconstruct instances. The point is: understanding that methods live on the prototype, not on instances, helps you predict these issues before they hit production.
Common mistakes
Mistake 1: Adding methods to instances instead of the prototype. If you're dynamically adding methods to individual instances (not during initialization), you're bypassing the memory efficiency of shared methods. We've seen this in legacy code where developers added methods in loops. It works, but it scales poorly.
Mistake 2: Assuming classes are different from functions. They're not. Understanding that typeof MyClass === 'function' and that class syntax desugars to constructor functions helps you read older codebases and understand what's actually happening.
Mistake 3: Forgetting that only instance properties are serialized. When you use JSON.stringify on a class instance, only own properties serialize. If you're transmitting objects across services and expecting methods to be available on the other side, you'll be disappointed. Design your serialization contracts accordingly.
Mistake 4: Misunderstanding this binding in callbacks. Passing methods as callbacks without binding is a classic source of production bugs. TypeScript helps here with strict type checking, but understanding the underlying mechanism makes the error obvious.
Key takeaways
- Classes are syntax sugar for constructor functions and prototypes—
typeof MyClassreturns'function'. - Methods defined in class bodies live on the prototype, not on instances, enabling memory-efficient sharing across all instances.
- Instance properties (defined in constructors) live on each instance; methods live on the prototype and are shared.
- When passing methods as callbacks, bind them or use arrow functions to preserve
this—understanding the prototype chain makes this gotcha predictable. - Serialization (JSON.stringify) only captures instance properties, not prototype methods; understand this when designing inter-service communication.
- Debugging issues with missing methods or unexpected
thisvalues becomes straightforward when you understand the prototype chain and that classes are constructor functions.






Comments
Loading comments…