JavaScript NotesRohit’s interview study guide
Chapter 04

Classes & OOP

ES classes, private fields, static blocks, inheritance, polymorphism, abstract classes, overloading and mixins.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Classes in JavaScript

A class is syntactic sugar over constructor functions and prototypes — the prototype chain underneath is exactly the same. But classes add a few real rules: they must be called with new, their bodies are always strict mode, and they're in the TDZ until declared (not hoisted like function declarations).

Before ES2015 you built the same thing by hand: write a constructor function, then bolt methods onto Constructor.prototype one assignment at a time, then wire up inheritance with Object.create and a call to the parent constructor (see What new actually does and Inheritance between constructor functions). It worked, but it was verbose and easy to get subtly wrong. class gives you one declaration that sets all of that up correctly.

The mental model: a class declaration creates two objects. The class itself is a function (the constructor) — it holds static members. Attached to it is Person.prototype, an ordinary object that holds the methods. Every instance is a thin object holding only its own data, with a hidden link to that prototype. Methods are looked up through the link, so a thousand instances share one copy of each method (see The prototype chain).

JavaScript
class Person {
  // Public instance field — one per instance
  species = "human";

  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  // Method — goes on Person.prototype, shared by all instances
  greet() {
    return `Hello, my name is ${this.name} and I'm ${this.age}.`;
  }

  // Static — lives on the class itself
  static compare(a, b) {
    return a.age - b.age;
  }
}

const john = new Person("John", 30);
john.greet();
typeof Person;                                 // "function"
Object.getPrototypeOf(john) === Person.prototype; // true
Person(); // TypeError: Class constructor Person cannot be invoked without 'new'
What’s happening
  1. Evaluating the class declaration creates the function Person (its body is the constructor), puts greet on Person.prototype, and puts compare directly on Person. Object.getOwnPropertyNames(Person.prototype) is ["constructor", "greet"]. The species field is not stored anywhere yet — it's remembered as an instruction to run for every new instance.
  2. new Person("John", 30) creates an empty object whose prototype is Person.prototype. Field initialisers run first, so it gets species: "human". Then the constructor body runs with this set to that object: name becomes "John", age becomes 30. Object.keys(john) is ["species", "name", "age"] — only data, no methods.
  3. john.greet() — john has no own greet, so the engine follows the link to Person.prototype, finds it there, and calls it with this = john → "Hello, my name is John and I'm 30.".
  4. typeof Person is "function" and john's prototype is Person.prototype — proof that a class is a constructor function plus a prototype object, nothing more exotic.
  5. compare lives on the class, so you call it as Person.compare(a, b) — for example people.sort(Person.compare) sorts youngest first. john.compare is undefined: statics are not on the prototype, so instances can't see them.
  6. Person() without new throws. A pre-ES2015 constructor function called without new would just run with the wrong this (the global object, or undefined in strict mode) and fail silently or confusingly; classes refuse up front.
AdvancedClasses vs constructor functions

The concrete differences from a hand-written constructor function:

  • Must be called with new — otherwise TypeError (step 6 above).
  • Always strict mode inside the class body, so a detached method gets this === undefined instead of the global object.
  • TDZ, not hoisting — new X() before class X {} throws ReferenceError: Cannot access 'X' before initialization. A function declaration could be used before its line.
  • Methods are non-enumerable. for...in over an instance doesn't list greet. With Person.prototype.greet = function () {}, it would.
  • Fields, #private members, static blocks and super have no clean equivalent in constructor-function code.

When to reach for a class: when you'll create many objects with the same shape and behaviour (models, UI components, errors), when a framework expects one (Angular services and components, web components extending HTMLElement, custom Error types), or when you want real private state with #. When you only need one object or a bag of related functions, a plain object or a module is simpler — there's nothing to instantiate.

#Class fields vs prototype methods (the gotcha)

How you write a method changes where it lives:

JavaScript
class Person {
  constructor(name) {
    this.name = name;
  }
  greetA() {}               // on Person.prototype — shared
  greetB = function () {};  // class field — a new function on EVERY instance
  greetC = () => this.name; // class field arrow — per instance, `this` locked
}

const a = new Person("a");
const b = new Person("b");
a.greetA === b.greetA;           // true
a.greetB === b.greetB;           // false
Object.hasOwn(a, "greetB");      // true — own property, not inherited
What’s happening
  1. greetA() {} is method syntax. It's created once, when the class is defined, and stored on Person.prototype. Both a and b find it by walking up to the prototype, so a.greetA === b.greetA is true — it's literally the same function object.
  2. greetB = function () {} is a class field. A field is an assignment the engine runs for every new, as if you'd written this.greetB = function () {} at the start of the constructor. So new Person("a") creates one function and new Person("b") creates another → false.
  3. Because fields are assigned onto the instance, Object.hasOwn(a, "greetB") is true, while Object.hasOwn(a, "greetA") is false. Object.keys(a) is ["greetB", "greetC", "name"] — the fields come first because field initialisers run before the constructor body in a base class.
  4. greetC = () => this.name is also per instance, but it's an arrow. An arrow takes this from where it was created — and a field initialiser runs with this set to the new instance. So greetC is permanently tied to its object: const { greetC } = a; greetC() still returns "a".
  5. Person.prototype.greetB is undefined. Fields never touch the prototype, which is the root of every trade-off below.
AdvancedCosts of arrow-field methods
AdvancedArrow field vs method as a callback

Here's that one good reason, side by side. Passing a method as a callback detaches it from its object:

JavaScript
class Button {
  label = "Save";
  clickMethod() {
    return this.label;
  }
  clickArrow = () => this.label;
}

const btn = new Button();
const method = btn.clickMethod; // what addEventListener / setTimeout would receive
const arrow = btn.clickArrow;

method(); // TypeError: Cannot read properties of undefined (reading 'label')
arrow();  // "Save"
What’s happening
  1. btn.clickMethod reads the function off the prototype. Storing it in method keeps the function but not the object — this is decided by how a function is called, not where it came from (see How this works).
  2. method() is a plain call with no object before the dot. Class bodies are strict mode, so this is undefined, and undefined.label throws.
  3. arrow is the per-instance arrow from the field. Its this was fixed to btn when the field initialiser ran, so arrow() returns "Save" no matter how it's called.
  4. The alternatives are btn.clickMethod.bind(btn) at the call site, or this.clickMethod = this.clickMethod.bind(this) in the constructor — the pattern React class components used for years. All three cost one function per instance; only the plain method is shared.
AdvancedPitfall: parent field beats child method

There's a second, nastier trap: a parent's field beats a child's method. Fields are own properties of the instance; methods live on the prototype. Own properties always win the lookup.

JavaScript
class Base {
  handle = () => "base field";
}
class Child extends Base {
  handle() {
    return "child method";
  }
}

new Child().handle(); // "base field" — the override never runs
What’s happening
  1. Child.prototype.handle exists, and you'd expect it to override the parent.
  2. But new Child() runs Base's field initialiser, which puts an own property handle on the instance.
  3. instance.handle() checks the instance first, finds the own arrow, and stops — it never reaches Child.prototype. Result: "base field".
  4. If Child also used a field (handle = () => "child field"), it would work, because the child's field is assigned after the parent's and overwrites it. Mixing the two styles across a hierarchy is what breaks.
AdvancedRule of thumb: methods vs arrow fields

Rule of thumb: use normal methods by default — they're shared, overridable and reachable with super. Use an arrow field only for a method you know will be passed around as a callback, and avoid it in classes designed to be extended.

#Private fields with #

Fields and methods starting with # are truly private: they can't be read, written or even detected from outside the class body. This is enforced by the language, unlike the old _underscore convention.

Why it exists: _balance was only a polite request — any code could still read and change it, and libraries routinely had users depend on "private" internals they then couldn't change. Closures gave real privacy but cost one copy of every method per instance. # gives you real privacy and shared prototype methods.

Mental model: a #name is not a property. Think of it as a hidden slot stamped onto each object the class constructs, and the class body as the only code holding the key to that slot. Because the key is lexical — it exists only in the source text of the class — no runtime trick (Object.keys, Reflect.ownKeys, JSON.stringify, a Proxy, a subclass) can reach it.

JavaScript
class BankAccount {
  #balance; // must be declared up front

  constructor(owner, balance) {
    this.owner = owner;
    this.#balance = balance;
  }

  getBalance() {
    return `The balance for ${this.owner} is $${this.#balance}`;
  }

  deposit(amount) {
    this.#validate(amount);
    this.#balance += amount;
    return `Deposited $${amount}. New balance is $${this.#balance}`;
  }

  withdraw(amount) {
    this.#validate(amount);
    if (amount > this.#balance) return "Insufficient balance.";
    this.#balance -= amount;
    return `Withdrew $${amount}. Remaining balance is $${this.#balance}`;
  }

  #validate(amount) { // private method
    if (amount <= 0) throw new RangeError("Amount must be positive");
  }

  static isAccount(obj) {
    return #balance in obj; // ergonomic brand check
  }
}

const account = new BankAccount("Alice", 1000);
account.deposit(500);   // Deposited $500. New balance is $1500
account.withdraw(200);  // Withdrew $200. Remaining balance is $1300
account.balance;        // undefined
// account.#balance;    // SyntaxError: Private field '#balance' must be declared in an enclosing class
What’s happening
  1. #balance; declares the private name. Every #name must be declared in the class body; using an undeclared one is a SyntaxError at parse time, so the whole file fails to load — not just that line.
  2. new BankAccount("Alice", 1000) stamps the #balance slot onto the new object (initially undefined), then the constructor sets the public owner to "Alice" and #balance to 1000. Object.keys(account) is ["owner"] and JSON.stringify(account) is '{"owner":"Alice"}' — the balance is invisible to both.
  3. deposit(500) calls the private method #validate(500), which passes, then #balance goes 1000 → 1500. withdraw(200) validates, checks 200 > 1500 (false), and takes it 1500 → 1300. withdraw(5000) would return "Insufficient balance." and leave it at 1300 — the class can enforce that rule because nothing else can touch the field.
  4. account.balance is undefined: balance and #balance are unrelated names. Writing account.balance = 1000000 just creates a new public property; getBalance() still reports $1300.
  5. isAccount uses #balance in obj, which is true for a real BankAccount and false for a look-alike such as { balance: 1 }. Without that check, reading obj.#balance on a foreign object throws TypeError: Cannot read private member #balance from an object whose class did not declare it — which is what BankAccount.prototype.getBalance.call({ owner: "Eve" }) does.
  6. The commented-out account.#balance is outside the class body, so the parser rejects it before any code runs.
AdvancedLimits of # private fields
AdvancedWhen to use # private members

Use # for state that must stay consistent (a balance, a cache, an internal state machine) and for helpers that aren't part of your public API — you can then rename or delete them without breaking anyone. The cost is that tests must go through the public methods, which is usually a good thing.

#Encapsulation recap

Encapsulation means hiding internal state and exposing only a controlled API.

It matters for two reasons. First, invariants: if only the class can change balance, the class can guarantee it's never negative. Second, freedom to change: anything outside code can't see, you can rewrite without breaking callers. JavaScript has had several ways to do it:

TechniqueTruly private?Notes
_name conventionNoJust a hint to other developers
Closures (factory / module pattern)YesEach instance has its own copies of methods
WeakMap keyed by instanceYesThe pre-2022 class trick
Symbol keysNoHidden from keys/JSON but discoverable
#private fieldsYesThe modern answer — use this
JavaScript
// Closure-based privacy (works without classes)
function createAccount(initial) {
  let balance = initial;
  return {
    deposit: (n) => (balance += n),
    get balance() {
      return balance;
    },
  };
}

const acc = createAccount(100);
acc.deposit(50); // 150
acc.balance;     // 150
acc.balance = 0; // ignored — there's no setter (a TypeError in strict mode)
acc.balance;     // 150
What’s happening
  1. createAccount(100) creates a local variable balance = 100. It's a variable, not a property, so the only code that can reach it is the two functions written inside createAccount — they carry it in their closure (see Closures).
  2. acc.deposit(50) runs balance += 50 → 150. An assignment expression evaluates to the new value, so the call returns 150.
  3. acc.balance calls the getter, which reads the closed-over variable → 150.
  4. acc.balance = 0 tries to assign to a getter-only property. In sloppy mode that's silently ignored; in strict mode (modules, classes) it throws TypeError: Cannot set property balance of #<Object> which has only a getter. Either way balance stays 150.
  5. The cost from the table: every createAccount call creates fresh deposit and getter functions, so a1.deposit === a2.deposit is false. Fine for a few objects, wasteful for a million.
AdvancedPrivacy with WeakMap and Symbol keys

The two middle rows of the table, for comparison:

JavaScript
// WeakMap: private data stored outside the object, keyed by it
const balances = new WeakMap();
class Account {
  constructor(initial) {
    balances.set(this, initial);
  }
  get balance() {
    return balances.get(this);
  }
}
new Account(5).balance;       // 5
Object.keys(new Account(5));  // []

// Symbol keys: hidden from casual inspection, but not private
const secret = Symbol("secret");
const obj = { [secret]: 1, x: 2 };
Object.keys(obj);                   // ["x"]
JSON.stringify(obj);                // '{"x":2}'
Object.getOwnPropertySymbols(obj);  // [Symbol(secret)] — found it
What’s happening
  1. The WeakMap version stores the balance outside the instance, in a map only the module can see. Methods are on the prototype (shared), and the data is private because nothing outside the module has balances. It's a WeakMap so that when an Account is garbage-collected, its entry disappears too.
  2. Object.keys(new Account(5)) is [] — the instance carries no data at all. This was how TypeScript and Babel compiled #private fields before engines supported them.
  3. The Symbol version hides secret from Object.keys and JSON.stringify, which is handy for metadata. But Object.getOwnPropertySymbols(obj) (or Reflect.ownKeys) returns the symbol, and then obj[symbol] reads the value 1. That's why the table says "No".
  4. Today, use #private in classes and closures in factory functions; the other two are mostly things you'll recognise in older code.

#Static members and static blocks

static properties and methods belong to the class, not to instances. Static initialisation blocks (ES2022) run once when the class is defined — useful for setup that needs several statements or access to private statics.

Statics are simply properties of the constructor function. They're the right home for anything that's about the type rather than one object: factory methods (Array.from, Promise.all, Date.now are all statics), constants, counters and caches. Before static blocks, multi-step setup had to happen after the class body, where it couldn't touch #private statics, or be squeezed into one expression.

JavaScript
class Config {
  static #instances = 0;
  static defaults;

  static {
    // runs once, when the class is evaluated
    const env = globalThis.process?.env?.NODE_ENV ?? "development";
    Config.defaults = { env, debug: env !== "production" };
  }

  constructor() {
    Config.#instances++;
  }

  static get count() {
    return Config.#instances;
  }
}

Config.defaults; // { env: "development", debug: true }
new Config();
Config.count;    // 1
What’s happening
  1. When the engine reaches the class Config declaration, it creates the class, then runs the static parts in source order: #instances is set to 0 on Config, defaults is created as undefined, then the static block runs. All of this happens once, before the next line of the file — a console.log placed after the class prints after the block.
  2. Inside the block, globalThis.process?.env?.NODE_ENV uses optional chaining because process doesn't exist in browsers. In Node with no NODE_ENV set, it's undefined, so ?? "development" kicks in. Config.defaults becomes { env: "development", debug: true }. Run with NODE_ENV=production and it's { env: "production", debug: false }.
  3. env is a block-scoped const, so it disappears after the block — it doesn't leak into the surrounding scope like a top-level variable would.
  4. new Config() runs the constructor, which increments the private static Config.#instances from 0 → 1. Code outside can't change that counter; it can only read it through the count getter → 1.
  5. Instances don't see statics: new Config().defaults is undefined. You always go through the class: Config.defaults, Config.count.
AdvancedInherited static members

Static members are inherited by subclasses (class Sub extends Config {} → Sub.defaults works), because Sub's own prototype is Config.

JavaScript
class Sub extends Config {}
Sub.defaults;                          // { env: "development", debug: true }
Object.hasOwn(Sub, "defaults");        // false — found on Config, not copied
Object.getPrototypeOf(Sub) === Config; // true

class Counter {
  static #n = 0;
  static get count() {
    return this.#n; // `this` is whichever class you called it on
  }
}
class SubCounter extends Counter {}
Counter.count;    // 0
SubCounter.count; // TypeError: Cannot read private member #n from an object whose class did not declare it
What’s happening
  1. extends links Sub itself to Config (as well as linking the prototypes). Looking up Sub.defaults finds nothing on Sub, follows the link to Config, and returns the same object. Nothing is copied — change Config.defaults and Sub.defaults changes too.
  2. Counter.count calls the getter with this = Counter, which owns #n → 0.
  3. SubCounter.count finds the inherited getter but calls it with this = SubCounter. Private statics are installed only on the class that declares them, so SubCounter.#n doesn't exist and the read throws.
  4. That's why Config above writes Config.#instances rather than this.#instances: naming the class explicitly keeps the getter working when it's inherited.

#Inheritance with extends and super

extends sets up two prototype links: Dog.prototype → Animal.prototype (so dog instances inherit animal methods) and Dog → Animal (so statics are inherited). super has two jobs: super(...) in a constructor runs the parent's constructor, and super.method() anywhere in a method calls the parent's version of a method.

The key difference from a base class: a derived class doesn't create its own object. The parent constructor creates it, and super() is the call that makes that happen. That design is what lets you extend built-ins like Error and Array, whose instances have special internal machinery only their own constructor can set up.

JavaScript
class Animal {
  constructor(name) {
    this.name = name;
  }
  speak() {
    return `${this.name} makes a sound`;
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);        // MUST be called before using `this`
    this.breed = breed;
  }
  speak() {
    return `${super.speak()} — Woof!`; // call the parent version
  }
}

new Dog("Rex", "Lab").speak(); // "Rex makes a sound — Woof!"
What’s happening
  1. new Dog("Rex", "Lab") starts running Dog's constructor, but this is uninitialised — no object exists yet, because Dog is a derived class.
  2. super(name) calls Animal's constructor with "Rex". Animal is a base class, so it creates the object — with its prototype set to Dog.prototype, because the engine remembers the original new target was Dog. It sets this.name = "Rex" and returns.
  3. Back in Dog, this now refers to that object. (Any fields declared in Dog would be initialised at this exact moment.) this.breed = "Lab" runs. The instance's own keys are ["name", "breed"].
  4. .speak() finds Dog.prototype.speak first, so the override wins over Animal.prototype.speak.
  5. Inside it, super.speak() looks up speak on the parent prototype (Animal.prototype) but calls it with the current this — the dog. That returns "Rex makes a sound", and the override appends " — Woof!".
  6. The final chain is rex → Dog.prototype → Animal.prototype → Object.prototype, so rex instanceof Animal is true.
AdvancedUsing this before super()
AdvancedParent constructor calling an override

The exact error is ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor — and as the second half says, you also get it if you write a constructor and forget super() entirely, even without touching this.

The order "parent constructor first, then child fields" produces a classic trap when a parent constructor calls an overridable method:

JavaScript
class Base {
  constructor() {
    this.init(); // calls the subclass override…
  }
  init() {}
}

class Widget extends Base {
  label = "ok";
  init() {
    console.log("init sees label =", this.label);
  }
}

new Widget(); // logs: init sees label = undefined
What’s happening
  1. new Widget() uses the implicit constructor, which calls super() straight away.
  2. Base's constructor runs and calls this.init(). this is the new widget, so lookup finds Widget.prototype.init — the override runs.
  3. But Widget's field label = "ok" hasn't been assigned yet: derived-class fields are initialised only after super() returns. So the override logs undefined.
  4. Once super() returns, label becomes "ok" — too late for init. The fix is to not call overridable methods from a constructor, or to have the caller run init() after construction.
AdvancedExtending built-ins: ValidationError

You can also extend built-ins:

JavaScript
class ValidationError extends Error {
  constructor(field, message) {
    super(message);
    this.name = "ValidationError";
    this.field = field;
  }
}
AdvancedCatching the custom ValidationError

Using it:

JavaScript
try {
  throw new ValidationError("email", "Email is required");
} catch (err) {
  if (err instanceof ValidationError) {
    console.log(`${err.field}: ${err.message}`); // email: Email is required
  }
  String(err); // "ValidationError: Email is required"
}
What’s happening
  1. super(message) runs the real Error constructor, which creates a genuine error object — it sets message to "Email is required" and captures the stack trace.
  2. this.name = "ValidationError" replaces the inherited name ("Error"). name is what String(err), err.stack's first line and most loggers print, so without it every custom error would just say Error: ….
  3. this.field = "email" adds structured data a handler can act on — far more useful than parsing the message string.
  4. In the catch, err instanceof ValidationError is true (and so is err instanceof Error), so you can handle validation failures differently from, say, network errors, and rethrow anything you don't recognise.
  5. To wrap a lower-level error, pass it as the cause (ES2022): super(message, { cause: originalError }).
AdvancedWhen inheritance is the right tool

Use inheritance like this when the relationship is a genuine, stable "is-a" and you want shared behaviour plus instanceof checks: error types, framework base classes, a shallow model hierarchy. Keep it shallow — every level is another constructor you must understand to know what this contains (see Composition over inheritance).

#Polymorphism and method overriding

Polymorphism: code written against a parent type works with any subclass, and each subclass can override a method to behave differently.

The mechanism is just prototype lookup. When you call obj.area(), the engine searches starting from obj, not from the class where the calling code was written. So a parent method that calls this.area() automatically gets whichever area the actual object provides. That's called dynamic dispatch: the method is chosen at call time, by the object that receives the call.

JavaScript
class Shape {
  area() {
    return 0;
  }
  describe() {
    return `${this.constructor.name} with area ${this.area().toFixed(2)}`;
  }
}

class Circle extends Shape {
  constructor(r) {
    super();
    this.r = r;
  }
  area() {
    return Math.PI * this.r ** 2;
  }
}

class Square extends Shape {
  constructor(s) {
    super();
    this.s = s;
  }
  area() {
    return this.s ** 2;
  }
}

[new Circle(1), new Square(2)].map((s) => s.describe());
// ["Circle with area 3.14", "Square with area 4.00"]
What’s happening
  1. Shape defines a default area() returning 0 and a describe() that calls this.area() — describe doesn't know or care which shape it's working on.
  2. new Circle(1) calls super() (Shape has no constructor, so the default one runs), then sets r = 1. new Square(2) does the same with s = 2.
  3. map calls describe() on the circle. Circle.prototype has no describe, so it's found on Shape.prototype — but it runs with this = the circle.
  4. this.constructor.name: constructor is found on Circle.prototype and points to Circle, so the name is "Circle". this.area() searches from the circle, finds Circle.prototype.area, and returns π × 1² = 3.14159…; toFixed(2) gives "3.14".
  5. For the square the same describe code finds Square.prototype.area → 2² = 4 → "4.00". A plain new Shape().describe() falls through to the default → "Shape with area 0.00".
  6. One method, three behaviours, chosen by the object. Because JavaScript doesn't check types, any object with area() and describe() would work here too — that's duck typing, polymorphism without a shared parent.
AdvancedCorrecting my notes: override signatures
AdvancedWhere polymorphism shows up

Where it shows up: a list of UI components that each render() differently, payment providers behind one charge() method, storage backends with the same get/set. Calling code handles them all the same way, and adding a new kind means adding a class rather than another if/switch branch everywhere.

AdvancedPitfall: class names in minified builds

#Method overloading (emulated)

JavaScript doesn't support overloading like Java or C++: a second method with the same name just replaces the first. (TypeScript has overload signatures, but there's still only one implementation.) You emulate it by inspecting the arguments:

JavaScript
class Dup {
  f() {
    return "first";
  }
  f() {
    return "second";
  }
}
new Dup().f(); // "second"
What’s happening
  1. Java picks an overload by the number and types of arguments at compile time. JavaScript has no types to pick by, and a method is just a property holding a function.
  2. When the class body is evaluated, the first f is written to Dup.prototype.f, then the second f is written to the same key and overwrites it. No error, no warning.
  3. So there is only ever one f, and it must handle every way it's called itself — which is what the next example does.
AdvancedA Calculator with an overloaded add
JavaScript
class Calculator {
  add(...args) {
    if (args.length === 1 && Array.isArray(args[0])) {
      return args[0].reduce((a, b) => a + b, 0);       // add([1, 2, 3])
    }
    if (args.every((a) => typeof a === "number")) {
      return args.reduce((a, b) => a + b, 0);          // add(1, 2, 3)
    }
    if (args.every((a) => typeof a === "string")) {
      return args.join("");                             // add("a", "b")
    }
    throw new TypeError("Unsupported arguments");
  }
}

const c = new Calculator();
c.add(1, 2);       // 3
c.add([1, 2, 3]);  // 6
c.add("a", "b");   // "ab"
What’s happening
  1. ...args collects every argument into a real array, so the method can inspect how many it got and what types they are.
  2. c.add(1, 2): args is [1, 2]. The first check fails (length is 2). The second passes — both are numbers — so reduce computes 0 + 1 + 2 → 3.
  3. c.add([1, 2, 3]): args is [[1, 2, 3]] — one argument that's an array — so the first branch sums the inner array → 6.
  4. c.add("a", "b"): not an array, not all numbers, all strings → join("") → "ab". A mix like c.add(1, "b") matches nothing and throws TypeError: Unsupported arguments.
  5. Order matters, and edge cases hide here: c.add() returns 0, because [].every(...) is true for an empty array (nothing fails the test), so the number branch runs with nothing to add.
AdvancedOptions objects instead of overloading

An options object is often cleaner than overloading: createUser({ name, email, role = "user" }).

JavaScript
function createUser({ name, email, role = "user" } = {}) {
  return { name, email, role };
}

createUser({ name: "Rohit", email: "r@x.dev" });             // { name: "Rohit", email: "r@x.dev", role: "user" }
createUser({ email: "a@x.dev", name: "Ana", role: "admin" }); // { name: "Ana", email: "a@x.dev", role: "admin" }
What’s happening
  1. The parameter is destructured, so each call passes one object and the function pulls out the keys it needs. Order doesn't matter — the second call lists email first and still works.
  2. role = "user" is a default that applies when the key is missing (or undefined), so the first call gets role: "user".
  3. = {} on the whole parameter means createUser() with no argument gives { name: undefined, email: undefined, role: "user" } instead of throwing on destructuring undefined.
  4. Unlike type-sniffing overloads, adding a new option later is just a new key — no existing call breaks, and every call site is self-describing.

#Abstract classes with new.target

JavaScript has no abstract keyword, but new.target tells a constructor which class new was called on. If it's the base class itself, refuse.

An abstract class is a base that exists only to be extended: it provides shared behaviour and says "subclasses must supply X". Java and TypeScript check that at compile time; in JavaScript you check it at runtime in the constructor.

JavaScript
class Shape {
  constructor() {
    if (new.target === Shape) {
      throw new TypeError("Shape is abstract — extend it instead");
    }
    if (this.area === Shape.prototype.area) {
      throw new TypeError(`${new.target.name} must implement area()`);
    }
  }
  area() {
    throw new Error("not implemented");
  }
}

class Rect extends Shape {
  constructor(w, h) {
    super();
    this.w = w;
    this.h = h;
  }
  area() {
    return this.w * this.h;
  }
}

new Rect(2, 3).area(); // 6
new Shape();           // TypeError: Shape is abstract — extend it instead
class Bad extends Shape {}
new Bad();             // TypeError: Bad must implement area()
What’s happening
  1. new Rect(2, 3) → Rect's constructor calls super(), which runs Shape's constructor. new.target is Rect (the class new was actually used on), so the first check passes.
  2. The second check reads this.area. The instance has no own area, so lookup finds Rect.prototype.area. Prototype methods exist from the moment the class is defined, so they're visible even this early. It's not the same function as Shape.prototype.area, so the check passes.
  3. Back in Rect, w = 2 and h = 3 are set; area() returns 6.
  4. new Shape() → new.target === Shape → throws immediately. The base can never be instantiated directly.
  5. new Bad() → new.target is Bad, so the first check passes. But Bad has no area, so this.area falls through to Shape.prototype.area → identical → throws, using new.target.name to name the offending subclass: "Bad must implement area()".
  6. Deeper subclasses are fine: class Sq extends Rect inherits Rect's area, so new Sq(4) passes the check. One trap: an arrow field area = () => … would fail it, because fields are assigned only after super() returns — the check runs before that and still sees Shape.prototype.area.
AdvancedTypeScript abstract vs runtime checks

TypeScript has a real abstract keyword that catches this at compile time with no runtime cost. In plain JavaScript, the new.target check is how you get the same guarantee.

Advancednew.target in plain functions

new.target on its own

new.target is undefined in a normal function call and refers to the constructor (or subclass) when called with new.

JavaScript
function Person(name) {
  if (!new.target) {
    return new Person(name); // called without new? fix it
  }
  this.name = name;
}

Person("Rohit").name; // "Rohit" — works with or without `new`
What’s happening
  1. Person("Rohit") is a normal call, so new.target is undefined and the guard is true.
  2. The function calls itself again, this time with new. Now new.target is Person, the guard is skipped, this.name = "Rohit" runs on the freshly created object, and that object is returned implicitly.
  3. The outer call returns that object, so .name is "Rohit". new Person("Rohit") skips straight to step 2 — both forms give you a proper instance.
  4. Built-ins make the same decision: Date() without new returns a string while new Date() returns an object, and Array(3) behaves like new Array(3).
  5. In a base-class constructor, new.target is the subclass being built — new Derived() sees new.target.name === "Derived" inside Base. That's exactly what the abstract-class check above relies on. Arrow functions don't have their own new.target; they see the enclosing function's.

#Mixins with Object.assign

A mixin is a bag of methods you copy into a class to share behaviour without a parent/child relationship — handy because a class can only extend one parent.

The problem it solves: a Duck can swim and fly, a Fish can only swim, a Plane can only fly. There's no single parent that gives each of them exactly the right abilities. Mixins let each class pick its abilities à la carte.

JavaScript
const canSwim = {
  swim() {
    return `${this.name} is swimming`;
  },
};
const canFly = {
  fly() {
    return `${this.name} is flying`;
  },
};

class Duck {
  constructor(name) {
    this.name = name;
  }
}
Object.assign(Duck.prototype, canSwim, canFly);

const d = new Duck("Donald");
d.swim(); // "Donald is swimming"
d.fly();  // "Donald is flying"
What’s happening
  1. canSwim and canFly are plain objects holding methods. They use this.name, so they quietly assume whatever host they're mixed into has a name — that unwritten contract is part of every mixin.
  2. Object.assign(Duck.prototype, canSwim, canFly) copies each source's own enumerable properties onto Duck.prototype, left to right. Afterwards Duck.prototype has constructor, swim and fly. The function objects are shared: Duck.prototype.swim === canSwim.swim is true.
  3. new Duck("Donald") gets an own name. d.swim() isn't on d, so lookup finds it on Duck.prototype and calls it with this = d → "Donald is swimming". Same for fly.
  4. It's a one-time copy. A method added to canSwim after the assign never reaches Duck.
  5. Copied methods are enumerable (unlike class methods), so for...in over a duck lists swim and fly. And there's no type relationship — you can't ask d instanceof canSwim; you check "swim" in d instead.
AdvancedMixins as subclass factories

A more composable style uses subclass factories:

JavaScript
const Serializable = (Base) =>
  class extends Base {
    serialize() {
      return JSON.stringify(this);
    }
  };
const Timestamped = (Base) =>
  class extends Base {
    createdAt = Date.now();
  };

class User {}
class SmartUser extends Serializable(Timestamped(User)) {}
What’s happening
  1. Serializable and Timestamped are functions that take a class and return a new anonymous class extending it. extends accepts any expression, which is what makes this possible.
  2. Timestamped(User) runs first (inner call), producing a class that extends User and declares a createdAt field. Serializable(…) wraps that in a class adding serialize(). SmartUser extends the result.
  3. The prototype chain is SmartUser.prototype → (Serializable layer) → (Timestamped layer) → User.prototype → Object.prototype. Each mixin is a real link in the chain, not a copy.
  4. new SmartUser() — none of the classes write a constructor, so the implicit ones pass super() calls up to User. On the way back down, the Timestamped layer's field runs and sets createdAt to the current time in milliseconds.
  5. new SmartUser().serialize() finds serialize on the Serializable layer and stringifies the instance's own enumerable properties → something like '{"createdAt":1791117878470}'. And instanceof User is still true.
  6. Advantages over Object.assign: super works inside mixin methods, methods stay non-enumerable, getters stay getters, and the order of application is explicit in the source.
AdvancedPitfall: getters and name clashes
AdvancedA getter frozen at copy time

Concretely: mixing in { get now() { return Date.now(); } } with Object.assign calls the getter once, at copy time, and stores that number — now never changes again. To copy getters as getters, use Object.defineProperties(Duck.prototype, Object.getOwnPropertyDescriptors(source)). And if two mixins both define hello, Object.assign(H.prototype, m1, m2) keeps m2's with no warning.

#Composition over inheritance

Deep inheritance trees get brittle: a change in a base class ripples everywhere, and real things rarely fit a single hierarchy (the classic "FlyingFish extends Fish or Bird?" problem). Composition builds objects from small, independent behaviours instead — "has-a" rather than "is-a".

Inheritance forces you to decide up front what a thing is, and you only get one answer. Composition asks what a thing can do, and lets you answer with any combination. The mental model is Lego rather than a family tree: each behaviour is a small brick, and an object is whatever bricks you snap together.

JavaScript
// Behaviours as small functions that work on shared state
const canEat = (state) => ({
  eat: (food) => `${state.name} eats ${food}`,
});
const canWalk = (state) => ({
  walk: () => `${state.name} walks`,
});
const canSwim = (state) => ({
  swim: () => `${state.name} swims`,
});

const createDog = (name) => {
  const state = { name };
  return { ...canEat(state), ...canWalk(state), ...canSwim(state) };
};
const createFish = (name) => {
  const state = { name };
  return { ...canEat(state), ...canSwim(state) };
};

createDog("Rex").walk();   // "Rex walks"
createFish("Nemo").swim(); // "Nemo swims"
What’s happening
  1. Each canX is a function that receives a state object and returns a small object of methods. The methods are closures over that state — they don't use this at all.
  2. createDog("Rex") creates state = { name: "Rex" } and calls three behaviour functions with the same state object. Spread merges their results into one object: { eat, walk, swim }.
  3. .walk() reads state.name from its closure → "Rex walks". If something changed state.name, every behaviour would see the change, because they share the one object.
  4. createFish("Nemo") simply leaves out canWalk, so createFish("Nemo").walk is undefined. A flying fish would be { ...canEat(s), ...canSwim(s), ...canFly(s) } — no hierarchy to argue about.
  5. With no this, methods survive being passed around: const { eat } = createDog("Rex"); eat("bone") → "Rex eats bone". The class version would have lost its this.
  6. The costs: every object gets fresh closures (memory per instance, like arrow fields), there's no instanceof to check, and if two behaviours return the same key, the later spread silently wins.

Built from Rohit’s “Javascript/HTML interview” study doc. Examples target modern browsers and Node 20+.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.