How do we use OOP in JavaScript
Use `class` for encapsulated kinds (component, model, service). Fields for state, methods on prototype, private fields with `#`, `static` for class-level helpers, `extends` + `super` for inheritance. Favor composition + small classes; avoid deep hierarchies. For pure-data shapes use plain objects + TypeScript types, not classes.
Class syntax
class User {
#id; // private field
name; // public field
static defaultRole = "member"; // static field
constructor(id, name) {
this.#id = id;
this.name = name;
}
get id() { return this.#id; } // getter
greet() { return `Hi, ${this.name}`; } // method (on prototype)
static fromJSON(json) { return new User(json.id, json.name); }
}
const u = User.fromJSON({ id: 1, name: "Sam" });
u.greet(); // "Hi, Sam"
u.id; // 1; #id is unreachable from outsidePieces of OO in JS
| Feature | Syntax |
|---|---|
| Public field | name = "x" |
| Private field | #secret |
| Method | greet() { } |
| Static method | static fromJSON() { } |
| Getter / setter | get x() { } / set x(v) { } |
| Inheritance | class B extends A { } |
| Super call | super(args); super.method() |
| Abstract-like | Throw in base method |
Inheritance
class Animal {
constructor(name) { this.name = name; }
speak() { return "?"; }
}
class Dog extends Animal {
constructor(name, breed) { super(name); this.breed = breed; }
speak() { return "woof"; }
}super(...) is required in subclass constructors before using this.
Composition over inheritance
For mixed capabilities, prefer composition:
class CartService {
constructor(deps) {
this.api = deps.api;
this.events = deps.events;
}
async add(item) {
await this.api.post("/cart", item);
this.events.emit("cart:add", item);
}
}Inject dependencies via constructor; easy to test (swap in mocks).
Mixins (use sparingly)
const Serializable = (Base) => class extends Base {
toJSON() { return JSON.stringify(this); }
};
class Foo extends Serializable(Object) {}Function-returning-class pattern for ad hoc mixins. Stack carefully — debugging gets hairy.
When OOP is overkill
For pure data:
type User = { id: number; name: string };
const u: User = { id: 1, name: "Sam" };No class needed. Use classes when:
- You have meaningful invariants enforced by the constructor.
- Methods belong to the data (
order.totalCents()). - Multiple instances with shared behavior.
Don't use classes when:
- You'd write a class with only a constructor and getters — just use a plain object.
- Data flows through reducers / immutable transforms.
- "Anaemic domain model" — class with only fields and trivial setters.
Modern patterns
- Service classes for app boundaries (ApiClient, AnalyticsClient).
- Models with behavior (Order with status transitions).
- Singletons for cross-cutting (Logger, Config).
- React class components are now rare — function components + hooks replace them.
Private + encapsulation
#field is hard-private — engine-level, can't be reflected, doesn't show up in Object.keys. Better than the legacy _underscore convention.
Static blocks
class C {
static cache = new Map();
static {
// run once at class definition
C.cache.set("default", new C());
}
}Interview framing
"Use class when the abstraction earns its keep — invariants, shared behavior across instances, real methods on data. Public/private fields with #, methods on prototype, statics for factories, getters for derived. Inherit with extends + super, but prefer composition: inject collaborators via constructor for testability. Plain objects + TypeScript types are fine for pure data — don't reach for class on every shape. React has mostly retired class components; for app code, OO shines for service boundaries and domain models with behavior."
Follow-up questions
- •When are classes vs plain objects + types?
- •Compare composition vs inheritance.
- •How do private fields differ from underscore convention?
Common mistakes
- •Deep inheritance hierarchies.
- •Class with only fields (anaemic).
- •Forgetting super() in subclass constructor.
- •Using _underscore instead of # for privacy.
Performance considerations
- •Class instances optimize well in V8. Arrow methods on instance vs prototype methods — minor memory tradeoff. Avoid mutating prototypes after construction.
Edge cases
- •this binding loss when method passed as callback.
- •Static fields per subclass — fresh per class, not shared.
- •Mixin chains.
Real-world examples
- •EventEmitter, Map/Set built-ins, Node streams, service classes in Angular, domain models in DDD.