Object-Oriented Programming

All 29 notes on one page

JavaScript

1

Objects Fundamentals

beginner objects properties methods Object.keys Object.freeze

Objects are the most important data structure in JavaScript. Almost everything in JS is an object or behaves like one. In simple language, an object is just a collection of key-value pairs — we call them properties. When a property’s value is a function, we call it a method.

Creating objects (object literals)

The simplest way to create an object is with curly braces. This is called an object literal:

const user = {
  name: "Manish",
  age: 25,
  greet() {          // shorthand method syntax
    console.log(`Hi, I'm ${this.name}`);
  }
};

That’s it. No class needed, no constructor — just a plain object.

Dot notation vs bracket notation

We have two ways to access properties:

console.log(user.name);      // "Manish" — dot notation
console.log(user["name"]);   // "Manish" — bracket notation

Dot notation is cleaner, but bracket notation is more powerful — we can use variables and strings with spaces or special characters:

const key = "age";
console.log(user[key]);      // 25 — dynamic access

const weird = { "full name": "Manish P" };
console.log(weird["full name"]); // bracket notation is required here

Computed property names

ES6 lets us use expressions as property names inside the object literal using square brackets:

const field = "email";
const profile = {
  [field]: "manish@example.com",       // key becomes "email"
  [`${field}Verified`]: true           // key becomes "emailVerified"
};

Shorthand syntax

When the variable name matches the property name, we can skip the colon:

const name = "Manish";
const age = 25;

const user = { name, age }; // same as { name: name, age: age }

Object.keys, Object.values, Object.entries

These three static methods are super handy for iterating over objects:

const car = { brand: "Toyota", year: 2022, color: "white" };

Object.keys(car);    // ["brand", "year", "color"]
Object.values(car);  // ["Toyota", 2022, "white"]
Object.entries(car); // [["brand", "Toyota"], ["year", 2022], ["color", "white"]]

Object.entries is especially useful with for...of loops or when we want to convert an object into a Map.

Object.freeze vs Object.seal

Both lock down an object, but in different ways:

  • Object.freeze() — no adding, removing, or modifying properties. The object becomes completely immutable (shallow).
  • Object.seal() — no adding or removing properties, but we can modify existing ones.
const frozen = Object.freeze({ x: 1 });
frozen.x = 99;     // silently fails (or throws in strict mode)
console.log(frozen.x); // 1

const sealed = Object.seal({ x: 1 });
sealed.x = 99;     // this works!
sealed.y = 2;      // silently fails — can't add new properties
console.log(sealed.x); // 99

One gotcha — both are shallow. Nested objects inside a frozen object can still be modified.

Object.assign

Object.assign copies properties from one or more source objects into a target object. It’s commonly used for merging objects or creating shallow copies:

const defaults = { theme: "dark", lang: "en" };
const userPrefs = { lang: "hi" };

const config = Object.assign({}, defaults, userPrefs);
console.log(config); // { theme: "dark", lang: "hi" }

These days, most of us prefer the spread operator { ...defaults, ...userPrefs } — it does the same thing and looks cleaner.

Checking if a property exists

We have two common approaches:

The in operator checks the object and its entire prototype chain:

const user = { name: "Manish" };
console.log("name" in user);     // true
console.log("toString" in user); // true — inherited from Object.prototype

hasOwnProperty() only checks the object’s own properties (not inherited ones):

console.log(user.hasOwnProperty("name"));     // true
console.log(user.hasOwnProperty("toString")); // false — it's inherited

The modern alternative is Object.hasOwn(obj, prop) which does the same thing as hasOwnProperty but works even if the object doesn’t have that method in its prototype.

Deleting properties

We can remove a property with the delete operator:

const obj = { a: 1, b: 2 };
delete obj.b;
console.log(obj); // { a: 1 }

delete returns true if the deletion succeeds. It doesn’t affect the prototype chain — only own properties.

In simple language, objects are just bags of key-value pairs. We create them with {}, access properties with dot or bracket notation, and use built-in methods like Object.keys(), Object.freeze(), and Object.assign() to work with them. Master these basics, and everything else in JavaScript OOP builds on top of this.


2

Constructor Functions & the new Keyword

beginner constructor new instanceof factory-functions

Before ES6 classes existed, constructor functions were the way to create multiple objects with the same shape. Think of a constructor function like a blueprint — we write it once and stamp out as many objects as we need.

A constructor function is just a regular function, but we follow two conventions:

  1. The name starts with a capital letter (like Person, Car, User).
  2. We call it with the new keyword.
function Person(name, age) {
  this.name = name;
  this.age = age;
  this.greet = function () {
    console.log(`Hi, I'm ${this.name}`);
  };
}

const manish = new Person("Manish", 25);
manish.greet(); // "Hi, I'm Manish"

The 4 steps new performs

When we write new Person("Manish", 25), JavaScript does four things behind the scenes:

  1. Creates an empty object{}
  2. Sets the prototype — links the new object’s [[Prototype]] to Person.prototype
  3. Calls the function with this bound to the new object
  4. Returns the object — if the function doesn’t explicitly return an object, JavaScript returns the newly created one

Here’s what that looks like if we did it manually:

// What "new Person('Manish', 25)" does under the hood:
const obj = {};                              // Step 1
Object.setPrototypeOf(obj, Person.prototype); // Step 2
Person.call(obj, "Manish", 25);              // Step 3
// Step 4: return obj (automatically)

Understanding these 4 steps is crucial — it explains why this works inside constructors and how prototype inheritance is set up.

What happens if we forget new?

If we call a constructor function without new, this will point to the global object (or be undefined in strict mode). That means properties get attached to the wrong place:

const oops = Person("Manish", 25); // forgot new!
console.log(oops);        // undefined (no return value)
console.log(window.name);  // "Manish" — leaked to global!

This is a common bug. ES6 classes fix this by throwing an error if we forget new.

Return value from constructors

If a constructor explicitly returns a primitive (string, number, etc.), JavaScript ignores it and returns the this object anyway. But if it returns an object, that object replaces this:

function Weird() {
  this.name = "original";
  return { name: "replaced" }; // returning an object overrides this
}
const w = new Weird();
console.log(w.name); // "replaced"
function Normal() {
  this.name = "original";
  return 42; // primitive return is ignored
}
const n = new Normal();
console.log(n.name); // "original"

instanceof

instanceof checks whether an object was created by a particular constructor (more precisely, whether the constructor’s .prototype exists in the object’s prototype chain):

const manish = new Person("Manish", 25);

console.log(manish instanceof Person); // true
console.log(manish instanceof Object); // true (everything inherits from Object)
console.log(manish instanceof Array);  // false

Factory functions vs constructors

A factory function is just a regular function that returns an object. No new keyword, no this:

function createPerson(name, age) {
  return {
    name,
    age,
    greet() {
      console.log(`Hi, I'm ${name}`); // closure, not this
    }
  };
}

const user = createPerson("Manish", 25);
Constructor Function
Factory Function
Keyword
new required
No new
this
Uses this
Uses closures
instanceof
Works
Doesn't work
Prototype sharing
Yes (memory efficient)
No (methods duplicated)

The main trade-off: factory functions give us true privacy through closures, but constructor functions let us share methods via the prototype (more memory efficient). In modern JS, most people use classes (which are constructor functions under the hood) and #private fields for privacy.

In simple language, a constructor function is a blueprint for creating objects. The new keyword does the heavy lifting — it creates an empty object, sets up the prototype link, runs the function, and returns the result. Understanding these 4 steps is the key to understanding how JavaScript’s object system works.


3

Prototypes & the Prototype Chain

intermediate prototype prototype-chain __proto__ Object.create inheritance

JavaScript doesn’t have classical inheritance like Java or C++. Instead, it uses something called prototypal inheritance — objects inherit directly from other objects. This is one of the most important concepts in JS, and once it clicks, a lot of confusing behavior suddenly makes sense.

Every object has a prototype

Every object in JavaScript has a hidden internal link called [[Prototype]]. It points to another object — the object’s prototype. When we try to access a property that doesn’t exist on the object, JavaScript follows this link and looks for the property on the prototype. If it’s not there either, it goes to the prototype’s prototype, and so on. This chain of lookups is the prototype chain.

myObj
{ name: "Manish" }
[[Prototype]]
Person.prototype
{ greet(), constructor }
[[Prototype]]
Object.prototype
{ toString(), hasOwnProperty(), ... }
[[Prototype]]
null
end of chain

__proto__ vs .prototype

This is where most people get confused. Let’s clear it up:

  • __proto__ (or [[Prototype]]) exists on every object. It’s the actual link to the object’s prototype. We should use Object.getPrototypeOf(obj) instead of __proto__ directly (it’s deprecated but still works).
  • .prototype exists only on functions. It’s the object that will become the [[Prototype]] of instances created with new.
function Person(name) {
  this.name = name;
}

const manish = new Person("Manish");

// manish.__proto__ === Person.prototype  (true)
// Person.prototype is the blueprint for instances
console.log(Object.getPrototypeOf(manish) === Person.prototype); // true

Think of it like this: Person.prototype is a stencil. When we use new Person(), the new object’s __proto__ gets pointed at that stencil.

How the prototype chain lookup works

When we access a property on an object, JavaScript does the following:

  1. Check the object itself — is the property there? If yes, use it.
  2. If not, check object.__proto__ (i.e., the prototype).
  3. If still not found, check object.__proto__.__proto__.
  4. Keep going until we hit null (end of the chain).
  5. If the property isn’t found anywhere, return undefined.
function Animal(type) {
  this.type = type;
}
Animal.prototype.speak = function () {
  return `${this.type} makes a sound`;
};

const dog = new Animal("Dog");

console.log(dog.type);     // "Dog" — found on dog itself
console.log(dog.speak());  // "Dog makes a sound" — found on Animal.prototype
console.log(dog.toString()); // "[object Object]" — found on Object.prototype

Object.create()

Object.create() creates a new object with a specific prototype. It’s the cleanest way to set up prototype-based inheritance without constructor functions:

const parent = {
  greet() {
    return `Hello, I'm ${this.name}`;
  }
};

const child = Object.create(parent); // child's [[Prototype]] is parent
child.name = "Manish";
console.log(child.greet()); // "Hello, I'm Manish"

We can also create an object with no prototype at all:

const bare = Object.create(null);
console.log(bare.toString); // undefined — no Object.prototype methods!

Property shadowing

When we set a property on an object that already exists on its prototype, the new property shadows (hides) the prototype’s version. The prototype property is still there — it’s just not reachable through this object anymore.

function Car(brand) {
  this.brand = brand;
}
Car.prototype.honk = function () { return "Beep!"; };

const myCar = new Car("Toyota");
console.log(myCar.honk()); // "Beep!" — from prototype

myCar.honk = function () { return "HONK!"; }; // shadows the prototype method
console.log(myCar.honk()); // "HONK!" — own property now

delete myCar.honk; // remove the shadow
console.log(myCar.honk()); // "Beep!" — prototype method is back

hasOwnProperty vs in

  • in checks the entire prototype chain
  • hasOwnProperty() (or the modern Object.hasOwn()) checks only the object’s own properties
const obj = Object.create({ inherited: true });
obj.own = true;

console.log("own" in obj);       // true
console.log("inherited" in obj); // true

console.log(obj.hasOwnProperty("own"));       // true
console.log(obj.hasOwnProperty("inherited")); // false

End of the chain

The prototype chain always ends at Object.prototype, whose own [[Prototype]] is null:

console.log(Object.getPrototypeOf(Object.prototype)); // null

When JavaScript reaches null, the lookup stops. If the property wasn’t found anywhere in the chain, we get undefined.

In simple language, the prototype chain is how JavaScript shares behavior between objects. When we look up a property, JS walks up the chain of linked objects until it finds what it’s looking for (or hits null). Every object is linked to a prototype, and that’s how methods like toString() magically work on every object even though we never defined them.


4

ES6 Classes

intermediate class constructor static syntactic-sugar

ES6 classes are one of those features that make JavaScript feel more like a “traditional” OOP language. But here’s the thing — classes in JavaScript are just syntactic sugar over the prototype system. Under the hood, they work exactly like constructor functions and prototypes. The class keyword just gives us a cleaner, more familiar syntax.

Basic class syntax

class Person {
  constructor(name, age) {
    this.name = name;    // instance property
    this.age = age;
  }

  greet() {              // instance method (goes on prototype)
    return `Hi, I'm ${this.name}`;
  }
}

const manish = new Person("Manish", 25);
console.log(manish.greet()); // "Hi, I'm Manish"

The constructor method runs automatically when we call new Person(...). Methods we define inside the class body (like greet) go on Person.prototype, not on each instance — so they’re shared, just like with constructor functions.

Classes are just functions

This is worth proving to ourselves:

console.log(typeof Person); // "function"
console.log(Person.prototype.greet); // [Function: greet]

A class is literally a function. The constructor becomes the function body, and class methods become prototype methods. So class Person { ... } is essentially doing the same thing as defining a constructor function and attaching methods to its prototype.

Static methods and properties

Static members belong to the class itself, not to instances. We use the static keyword:

class MathHelper {
  static PI = 3.14159;

  static square(x) {
    return x * x;
  }
}

console.log(MathHelper.PI);         // 3.14159
console.log(MathHelper.square(5));  // 25

// const m = new MathHelper();
// m.square(5);  // TypeError — static methods aren't on instances

Think of static methods like utility functions that are related to the class but don’t need an instance. Array.isArray(), Object.keys(), Number.isNaN() — all static methods.

Class expressions

Just like functions, classes can be expressions too:

const Animal = class {
  constructor(type) {
    this.type = type;
  }
};

// Named class expression
const Vehicle = class Car {
  // "Car" is only accessible inside this class body
};

Class expressions are less common, but they come in handy when we need to dynamically create classes or pass them around.

Class hoisting (TDZ)

This is a key difference from function declarations. Classes are NOT hoisted like functions. They sit in the Temporal Dead Zone (TDZ) just like let and const:

// const p = new Person(); // ReferenceError!

class Person {
  constructor() {
    this.name = "Manish";
  }
}

const p = new Person(); // works here

With function declarations, we can call them before the line they’re declared on. With classes, we can’t. We must define the class before using it.

Must use new

Unlike constructor functions, classes cannot be called without new. JavaScript throws an error:

// Person("Manish"); // TypeError: Class constructor Person cannot be invoked without 'new'
const p = new Person("Manish"); // correct

This is actually a nice safety feature — remember how forgetting new with constructor functions silently messes up this? Classes prevent that.

Class fields (public instance fields)

We can declare properties directly in the class body without putting them in the constructor:

class Counter {
  count = 0;       // public field — each instance gets its own copy

  increment() {
    this.count++;
  }
}

const c = new Counter();
c.increment();
console.log(c.count); // 1

Class fields are defined on the instance, not on the prototype. They run at the beginning of the constructor (or when super() returns in subclasses).

Methods are non-enumerable

One subtle difference from manually attaching methods to a prototype — class methods are non-enumerable. This means they won’t show up in for...in loops or Object.keys():

class Dog {
  bark() { return "Woof!"; }
}

const d = new Dog();
console.log(Object.keys(d)); // [] — bark is not enumerable

This is actually the correct behavior. We usually don’t want methods to show up when we iterate over an object’s properties.

Putting it all together

Here’s a practical example that uses most of what we’ve covered:

class User {
  static count = 0;  // static field

  constructor(name, role) {
    this.name = name;
    this.role = role;
    User.count++;     // track how many users were created
  }

  describe() {
    return `${this.name} (${this.role})`;
  }

  static totalUsers() {
    return `Total users: ${User.count}`;
  }
}

const u1 = new User("Manish", "admin");
const u2 = new User("Priya", "editor");
console.log(u1.describe());      // "Manish (admin)"
console.log(User.totalUsers());  // "Total users: 2"

In simple language, ES6 classes are a nicer way to write constructor functions and prototype methods. They don’t introduce a new inheritance model — they just make the existing prototype system easier to read and write. Under the hood, typeof MyClass is still "function", and methods still live on the prototype.

References


5

Inheritance: extends & super

intermediate extends super inheritance method-overriding

Inheritance lets us create a new class based on an existing one. The child class gets all the properties and methods of the parent, and can add its own or override what it inherited. In JavaScript, we use extends to set up this relationship and super to talk to the parent.

Basic inheritance with extends

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    return `${this.name} makes a sound`;
  }
}

class Dog extends Animal {
  bark() {
    return `${this.name} says Woof!`;
  }
}

const rex = new Dog("Rex");
console.log(rex.speak()); // "Rex makes a sound" — inherited from Animal
console.log(rex.bark());  // "Rex says Woof!" — own method

Dog inherits everything from Animal. We didn’t define a constructor in Dog, so JavaScript automatically calls the parent’s constructor for us.

super() in the constructor

When a child class has its own constructor, we must call super() before using this. This calls the parent’s constructor and sets up the instance properly:

class Animal {
  constructor(name) {
    this.name = name;
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);        // MUST call super() first!
    this.breed = breed; // now we can use this
  }
}

const rex = new Dog("Rex", "German Shepherd");
console.log(rex.name);  // "Rex"
console.log(rex.breed); // "German Shepherd"

If we forget super() or try to use this before calling it, JavaScript throws a ReferenceError. This is a hard rule — no exceptions.

Method overriding

A child class can define a method with the same name as the parent’s method. This is called method overriding — the child’s version replaces the parent’s for instances of the child class:

class Animal {
  speak() {
    return "Some generic sound";
  }
}

class Cat extends Animal {
  speak() {
    return "Meow!"; // overrides Animal's speak()
  }
}

const cat = new Cat();
console.log(cat.speak()); // "Meow!" — child's version wins

super.method() for parent method calls

Sometimes we don’t want to completely replace the parent’s method — we want to extend it. We can call the parent’s version using super.methodName():

class Animal {
  speak() {
    return "Some sound";
  }

  describe() {
    return `I am a ${this.constructor.name}`;
  }
}

class Dog extends Animal {
  speak() {
    const parentResult = super.speak(); // call parent's speak
    return `${parentResult}... actually, Woof!`;
  }
}

const d = new Dog();
console.log(d.speak()); // "Some sound... actually, Woof!"

Class hierarchy diagram

Here’s how a typical class hierarchy looks with prototype links:

Animal
constructor(name)
speak()
extends
Dog
constructor(name, breed)
bark()
speak() - overridden
Cat
constructor(name, indoor)
purr()
speak() - overridden
extends
GuideDog
constructor(name, breed, handler)
guide()

Inheriting static methods

Static methods are inherited too! The child class can access the parent’s static methods:

class Animal {
  static kingdom() {
    return "Animalia";
  }
}

class Dog extends Animal {}

console.log(Dog.kingdom()); // "Animalia" — inherited static method

This works because Dog.__proto__ === Animal (the class itself, not the prototype). Static methods live on the constructor function, and the child constructor’s prototype points to the parent constructor.

instanceof with inheritance

instanceof walks up the prototype chain, so it returns true for the child class and all its ancestors:

const rex = new Dog("Rex", "Lab");

console.log(rex instanceof Dog);    // true
console.log(rex instanceof Animal); // true
console.log(rex instanceof Object); // true

Multi-level inheritance

We can chain inheritance as deep as we want, but in practice, going more than 2-3 levels deep usually means we should rethink our design (favor composition over deep inheritance):

class Animal {
  constructor(name) { this.name = name; }
  eat() { return `${this.name} eats`; }
}

class Dog extends Animal {
  bark() { return `${this.name} barks`; }
}

class GuideDog extends Dog {
  constructor(name, handler) {
    super(name);
    this.handler = handler;
  }
  guide() { return `${this.name} guides ${this.handler}`; }
}

const buddy = new GuideDog("Buddy", "Alex");
console.log(buddy.eat());   // "Buddy eats" — from Animal
console.log(buddy.bark());  // "Buddy barks" — from Dog
console.log(buddy.guide()); // "Buddy guides Alex" — own method

Common pitfall: forgetting super()

This is the number one mistake beginners make. If our child class has a constructor, we must call super() before doing anything with this:

class Child extends Parent {
  constructor() {
    // this.x = 10; // ReferenceError! Must call super() first
    super();
    this.x = 10;    // now it's fine
  }
}

The reason is that this doesn’t exist in the child constructor until super() creates it. This is by design — it ensures the parent gets to initialize the object first.

In simple language, extends tells JavaScript “this class is based on that class”, and super is how the child talks to the parent. super() in the constructor calls the parent’s constructor, and super.method() calls the parent’s version of a method. Together they give us clean, readable class hierarchies.

References


6

Encapsulation & Private Fields

intermediate encapsulation private-fields WeakMap closures information-hiding

Encapsulation means hiding the internal details of an object and only exposing what’s necessary. Think of it like a car — we use the steering wheel and pedals (public interface) without needing to know how the engine works internally (private details).

In many languages, we get private, protected, and public keywords. JavaScript didn’t have real privacy for a long time, so developers came up with creative workarounds. Now, with ES2022, we finally have native private fields using the # syntax.

The # private fields (ES2022)

This is the modern, official way to create truly private properties. We prefix the name with #:

class BankAccount {
  #balance = 0;        // private field

  constructor(initial) {
    this.#balance = initial;
  }

  deposit(amount) {
    this.#balance += amount;
  }

  getBalance() {
    return this.#balance; // accessible inside the class
  }
}

const acc = new BankAccount(100);
acc.deposit(50);
console.log(acc.getBalance()); // 150
// console.log(acc.#balance);  // SyntaxError! Can't access from outside

The # is part of the field name — #balance and balance are two completely different properties. Trying to access #balance from outside the class body is a SyntaxError, not a runtime error. The privacy is enforced by the engine itself.

Private methods

We can make methods private too:

class User {
  #password;

  constructor(name, password) {
    this.name = name;
    this.#password = password;
  }

  #encrypt(value) {         // private method
    return btoa(value);     // simple encoding for demo
  }

  getEncryptedPassword() {
    return this.#encrypt(this.#password);
  }
}

const u = new User("Manish", "secret123");
console.log(u.getEncryptedPassword()); // "c2VjcmV0MTIz"
// u.#encrypt("test");  // SyntaxError!

Private static fields

Static fields can be private too — useful for things like instance counts or shared secrets:

class Connection {
  static #maxConnections = 5;
  static #activeCount = 0;

  constructor() {
    if (Connection.#activeCount >= Connection.#maxConnections) {
      throw new Error("Too many connections");
    }
    Connection.#activeCount++;
  }

  static getActiveCount() {
    return Connection.#activeCount;
  }
}

Closure-based privacy (pre-ES2022)

Before # fields existed, closures were the go-to pattern for privacy. The idea is that variables declared inside a function are only accessible within that function’s scope:

function createUser(name, password) {
  // password is truly private — it's a closure variable
  return {
    name,
    checkPassword(input) {
      return input === password;
    }
  };
}

const user = createUser("Manish", "secret");
console.log(user.name);              // "Manish"
console.log(user.checkPassword("secret")); // true
console.log(user.password);          // undefined — not accessible

This still works great, but the downside is that we can’t use it with class syntax, and methods aren’t shared on the prototype (each instance gets its own copy).

WeakMap for private data

Another pre-ES2022 pattern uses a WeakMap to store private data keyed by the instance:

const _data = new WeakMap();

class User {
  constructor(name, secret) {
    _data.set(this, { secret }); // store private data
    this.name = name;
  }

  getSecret() {
    return _data.get(this).secret;
  }
}

const u = new User("Manish", "hidden");
console.log(u.getSecret()); // "hidden"
console.log(u.secret);      // undefined

The WeakMap is defined outside the class, so it’s not accessible to consumers. And because it’s a weak map, the entries are garbage-collected when the instance is garbage-collected. Clever, but now that we have # fields, there’s rarely a reason to use this pattern.

The _underscore convention

This is the oldest approach — just prefix the property with an underscore to signal “hey, don’t touch this from outside”:

class Config {
  constructor() {
    this._apiKey = "abc123"; // convention: treat as private
  }
}

But it’s not real privacy. Anyone can still access config._apiKey. It’s just a naming convention, a gentleman’s agreement. We see it a lot in older codebases and some libraries.

Symbols as pseudo-private keys

Using Symbol as property keys makes properties hard to access accidentally (they won’t show up in Object.keys or for...in), but they’re not truly private — someone can still find them with Object.getOwnPropertySymbols():

const _secret = Symbol("secret");

class Vault {
  constructor(value) {
    this[_secret] = value;
  }

  reveal() {
    return this[_secret];
  }
}

const v = new Vault("treasure");
console.log(v.reveal()); // "treasure"
// console.log(v[_secret]); // only works if you have the Symbol reference

Comparison of approaches

Approach
True Privacy?
Works with class?
# private fields
Yes (engine-enforced)
Yes
Closures
Yes
No (factory only)
WeakMap
Yes
Yes
_underscore
No (convention only)
Yes
Symbol keys
Pseudo-private
Yes

In simple language, encapsulation is about hiding the stuff that the outside world doesn’t need to see. In modern JavaScript, use # private fields — they’re simple, enforced by the engine, and work perfectly with classes. The older patterns (closures, WeakMap, underscores, Symbols) are good to know for reading legacy code, but for new code, # is the way to go.


7

Polymorphism in JavaScript

intermediate polymorphism method-overriding duck-typing Symbol.hasInstance

Polymorphism is a fancy word for a simple idea: same interface, different behavior. We call the same method on different objects, and each one responds in its own way. In simple language, polymorphism means “many forms” — the same action can look different depending on who’s doing it.

Languages like Java have strict polymorphism through interfaces and abstract classes. JavaScript takes a more relaxed approach — we get polymorphism through method overriding and duck typing, without needing formal interfaces.

Method overriding (runtime polymorphism)

This is the most common form of polymorphism in JS. A child class overrides a method from the parent, so calling the same method on different instances produces different results:

class Shape {
  area() {
    return 0;
  }
}

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

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

Now we can write code that works with any shape without caring about the specific type:

const shapes = [new Circle(5), new Rectangle(4, 6)];

shapes.forEach(shape => {
  console.log(shape.area()); // each shape calculates its own area
});
// 78.54  (circle)
// 24     (rectangle)

This is the power of polymorphism — the forEach loop doesn’t know or care whether it’s a circle or rectangle. It just calls .area() and trusts each object to do the right thing.

Duck typing

JavaScript doesn’t have interfaces or type checking at runtime (unless we add it ourselves). Instead, it follows a philosophy called duck typing: “If it walks like a duck and quacks like a duck, it’s a duck.”

In simple language, we don’t check what an object is — we check what it can do. If it has the method we need, we call it. Doesn’t matter if it’s a class instance, a plain object, or anything else:

class Duck {
  quack() { return "Quack!"; }
}

class Person {
  quack() { return "I'm pretending to quack!"; }
}

const fakeQuacker = { quack: () => "Rubber ducky!" };

function makeItQuack(thing) {
  // We don't check if thing is a Duck
  // We just check if it can quack
  if (typeof thing.quack === "function") {
    console.log(thing.quack());
  }
}

makeItQuack(new Duck());     // "Quack!"
makeItQuack(new Person());   // "I'm pretending to quack!"
makeItQuack(fakeQuacker);    // "Rubber ducky!"

All three objects work because they all have a quack() method. That’s duck typing in action.

toString() and valueOf() overriding

Every object in JavaScript inherits toString() and valueOf() from Object.prototype. We can override them to control how our objects behave in string and numeric contexts:

class Money {
  constructor(amount, currency) {
    this.amount = amount;
    this.currency = currency;
  }

  toString() {
    return `${this.currency} ${this.amount.toFixed(2)}`;
  }

  valueOf() {
    return this.amount;
  }
}

const price = new Money(49.99, "USD");
console.log(`Price: ${price}`);    // "Price: USD 49.99" — calls toString()
console.log(price + 10);           // 59.99 — calls valueOf()

This is polymorphism too — the same operators (+, template literals) produce different results depending on what toString() and valueOf() return.

Symbol.toPrimitive

Symbol.toPrimitive gives us even more control. It’s a method that JavaScript calls when it needs to convert an object to a primitive. It receives a hint — either "string", "number", or "default":

class Temperature {
  constructor(celsius) {
    this.celsius = celsius;
  }

  [Symbol.toPrimitive](hint) {
    if (hint === "string") return `${this.celsius}°C`;
    if (hint === "number") return this.celsius;
    return this.celsius; // default
  }
}

const temp = new Temperature(36.6);
console.log(`Body temp: ${temp}`); // "Body temp: 36.6°C" (string hint)
console.log(temp + 0);             // 36.6 (number hint)

When Symbol.toPrimitive is defined, it takes priority over toString() and valueOf().

Symbol.hasInstance

Symbol.hasInstance lets us customize how instanceof works. This is a more advanced form of polymorphism — we’re changing how the type-checking mechanism itself behaves:

class EvenNumber {
  static [Symbol.hasInstance](value) {
    return typeof value === "number" && value % 2 === 0;
  }
}

console.log(4 instanceof EvenNumber);  // true
console.log(7 instanceof EvenNumber);  // false
console.log("hello" instanceof EvenNumber); // false

We’ve redefined what it means to be an “instance” of EvenNumber. Now instanceof doesn’t check the prototype chain — it runs our custom logic instead.

Ad-hoc polymorphism through operator context

JavaScript operators behave differently depending on the types of their operands. The + operator is the classic example:

console.log(5 + 3);       // 8 — numeric addition
console.log("5" + 3);     // "53" — string concatenation
console.log(true + true);  // 2 — boolean to number coercion

This isn’t something we design — it’s built into the language. But when we override toString(), valueOf(), or Symbol.toPrimitive, we’re plugging into this ad-hoc polymorphism and deciding how our objects behave with these operators.

Practical example: a pluggable logger

Here’s a real-world use of polymorphism. Different loggers implement the same interface, and we can swap them out without changing the code that uses them:

class ConsoleLogger {
  log(msg) { console.log(`[CONSOLE] ${msg}`); }
}

class FileLogger {
  log(msg) { console.log(`[FILE] Writing: ${msg}`); }
}

class SilentLogger {
  log(msg) { /* do nothing */ }
}

function processOrder(order, logger) {
  logger.log(`Processing order #${order.id}`);
  // ... business logic
  logger.log(`Order #${order.id} complete`);
}

// Swap loggers without changing processOrder
processOrder({ id: 42 }, new ConsoleLogger());
processOrder({ id: 43 }, new SilentLogger());

This is polymorphism and duck typing working together. processOrder doesn’t care which logger it gets — it just needs something with a .log() method.

In simple language, polymorphism in JavaScript means we can call the same method on different objects and get different results. We achieve this through method overriding (class hierarchies) and duck typing (if it has the method, just call it). Unlike strict languages, JavaScript doesn’t need interfaces or type declarations — if the method exists, it works.


8

Abstraction Patterns

intermediate abstraction factory-functions interface-emulation information-hiding

Abstraction is about hiding the messy details and showing only what matters. Think of it like driving a car — we use the steering wheel and pedals, but we don’t need to know how the engine combustion works internally. In OOP, abstraction means we expose a clean interface and tuck away the complexity behind it.

Why Abstraction Matters

Without abstraction, every part of our code knows about every other part’s internals. One small change breaks everything. Abstraction gives us:

  • Simpler usage — callers only see what they need
  • Easier maintenance — we can change internals without breaking consumers
  • Enforced contracts — subclasses must implement certain methods

JS Has No abstract Keyword

Languages like Java and TypeScript have abstract classes that can’t be instantiated directly and force subclasses to implement certain methods. JavaScript doesn’t have this built-in. But we can fake it.

The trick is to throw an error in the base class method. If a subclass forgets to override it, the error tells them immediately.

class PaymentProcessor {
  process(amount) {
    throw new Error("Subclass must implement process()"); // enforce the contract
  }
  refund(transactionId) {
    throw new Error("Subclass must implement refund()");
  }
}

class StripeProcessor extends PaymentProcessor {
  process(amount) {
    console.log(`Charging $${amount} via Stripe`);
  }
  refund(transactionId) {
    console.log(`Refunding ${transactionId} via Stripe`);
  }
}

If someone creates a PaypalProcessor but forgets to implement refund(), they’ll get a clear error the first time it’s called. Not perfect, but it works.

Preventing Direct Instantiation

We can also stop anyone from creating an instance of the base class directly using new.target:

class Shape {
  constructor() {
    if (new.target === Shape) {
      throw new Error("Shape is abstract — use a subclass instead");
    }
  }
  area() {
    throw new Error("Subclass must implement area()");
  }
}

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

// new Shape();        // Error: Shape is abstract
// new Circle(5).area() // 78.54

new.target tells us which constructor was actually called. If someone does new Shape() directly, new.target is Shape. If they do new Circle(5), new.target is Circle even though Shape’s constructor runs.

Factory Functions as Abstraction

Factory functions are one of the cleanest abstraction patterns in JS. The caller doesn’t know (or care) what’s happening inside — they just get an object back.

function createLogger(type) {
  if (type === "console") {
    return { log: (msg) => console.log(`[LOG] ${msg}`) };
  }
  if (type === "silent") {
    return { log: () => {} }; // does nothing, same interface
  }
  throw new Error(`Unknown logger type: ${type}`);
}

const logger = createLogger("console");
logger.log("Server started"); // [LOG] Server started

The caller just calls createLogger() and gets something with a .log() method. It doesn’t care whether it’s writing to the console, a file, or nowhere. That’s abstraction.

Symbol.species — Controlling Derived Constructors

When we extend built-in classes like Array, methods like .map() and .filter() return an instance of our subclass by default. Symbol.species lets us control this.

class TrackedArray extends Array {
  static get [Symbol.species]() {
    return Array; // map/filter should return plain Arrays, not TrackedArrays
  }
}

const tracked = new TrackedArray(1, 2, 3);
const mapped = tracked.map((x) => x * 2);

console.log(mapped instanceof TrackedArray); // false
console.log(mapped instanceof Array);        // true

This is useful when our subclass has expensive setup (like tracking or validation) that we don’t want to trigger for every intermediate array operation.

When Abstraction Helps vs When It Hurts

Abstraction is powerful, but overdoing it is a real problem. Here’s a simple rule:

  • Good abstraction — hides complexity that callers genuinely don’t need to know about
  • Bad abstraction — adds layers of indirection that make debugging harder without real benefit

If we have only one implementation of an “interface” and we’ll probably never have another — just use the concrete thing directly. We don’t need a DatabaseInterface base class if we’re only ever going to use PostgreSQL. We can always add the abstraction later when a second implementation actually shows up.

In simple language, abstraction in JavaScript is about exposing a simple surface while keeping the complex wiring hidden. We don’t have abstract classes like Java, but throwing errors in base methods and using factory functions gets us most of the way there — without overcomplicating things.


9

this Keyword & Execution Context

intermediate this bind call apply arrow-functions execution-context

this is one of the most confusing parts of JavaScript, and it comes up in almost every OOP interview. The key thing to understand: this is NOT fixed when we write the code — it’s determined when the function is called. The same function can have different this values depending on how we call it.

The Rules of this

There are a few simple rules. Once we know them, this stops being mysterious.

How is this determined?
1. Arrow function?
Uses this from surrounding scope (lexical). Can't be changed.
↓ no
2. Called with new?
this = the newly created object.
↓ no
3. Called with call/apply/bind?
this = whatever we explicitly pass in.
↓ no
4. Called as obj.method()?
this = the object before the dot.
↓ no
5. Plain function call
this = undefined (strict mode) or window (sloppy mode).

this in Global Context

In the global scope (outside any function), this refers to the global object — window in browsers, globalThis everywhere.

console.log(this === window); // true (in a browser)

this in Regular Functions

For a regular function, this depends on how it’s called, not where it’s written.

function showThis() {
  console.log(this);
}

showThis();          // window (sloppy mode) or undefined (strict mode)

const obj = { name: "Manish", showThis };
obj.showThis();      // { name: "Manish", showThis: f } — the object before the dot

The same function, two different this values. That’s the core idea.

this in Object Methods

When we call a method on an object, this is the object that owns the method — the thing before the dot.

const user = {
  name: "Manish",
  greet() {
    console.log(`Hi, I'm ${this.name}`);
  }
};

user.greet(); // Hi, I'm Manish — this = user

this in Constructors

When we use new, JavaScript creates a fresh empty object and sets this to point to it.

function Person(name) {
  this.name = name; // this = the new object being created
}

const p = new Person("Manish");
console.log(p.name); // "Manish"

Arrow Functions & Lexical this

Arrow functions don’t get their own this. They capture this from wherever they were defined. This is called lexical this and it’s one of the biggest reasons arrow functions exist.

const team = {
  name: "Avengers",
  members: ["Tony", "Steve"],
  printMembers() {
    this.members.forEach((member) => {
      console.log(`${member} is in ${this.name}`); // this = team (from printMembers)
    });
  }
};

team.printMembers(); // Tony is in Avengers, Steve is in Avengers

If we used a regular function inside forEach, this would be undefined (strict mode) instead of team. Arrow functions save us here.

call(), apply(), and bind()

These let us explicitly set this:

  • call(thisArg, arg1, arg2, ...) — calls the function immediately with the given this
  • apply(thisArg, [argsArray]) — same as call, but arguments are an array
  • bind(thisArg) — returns a new function with this permanently set
function greet(greeting) {
  console.log(`${greeting}, I'm ${this.name}`);
}

const user = { name: "Manish" };

greet.call(user, "Hey");     // Hey, I'm Manish
greet.apply(user, ["Hey"]);  // Hey, I'm Manish

const boundGreet = greet.bind(user);
boundGreet("Hey");           // Hey, I'm Manish

The only difference between call and apply is how we pass arguments — individually vs as an array.

this in Event Handlers

In DOM event handlers, this is the element that the listener is attached to.

button.addEventListener("click", function () {
  console.log(this); // the button element
  this.style.color = "red";
});

// But with arrow function:
button.addEventListener("click", () => {
  console.log(this); // NOT the button — it's the outer this (probably window)
});

This is one case where we actually want a regular function, not an arrow function.

The Classic Pitfall: Losing this

The most common bug with this happens when we pass a method as a callback. The method gets detached from its object and this becomes undefined.

class Timer {
  constructor() {
    this.seconds = 0;
  }
  start() {
    // BUG: regular function loses this
    // setInterval(function() { this.seconds++; }, 1000); // this = undefined!

    // FIX 1: arrow function (inherits this from start())
    setInterval(() => { this.seconds++; }, 1000);

    // FIX 2: bind
    // setInterval(function() { this.seconds++; }.bind(this), 1000);
  }
}

Whenever we see this inside a callback and it’s not working, the first thing to check is whether we’re using an arrow function or need to .bind(this).

this in Classes

Class methods behave like object methods — this is the instance. But the same “losing this” problem applies if we pass a method as a callback.

class Logger {
  prefix = "[LOG]";
  log(msg) {
    console.log(`${this.prefix} ${msg}`);
  }
}

const logger = new Logger();
logger.log("hello");           // [LOG] hello

const fn = logger.log;
// fn("hello");                // TypeError: Cannot read properties of undefined

const boundFn = logger.log.bind(logger);
boundFn("hello");              // [LOG] hello

In simple language, this in JavaScript isn’t about where we write the function — it’s about how we call it. Arrow functions are the exception because they lock in this from their surrounding scope. When in doubt, check the call site or use .bind().

References


10

Getters, Setters & Proxy

intermediate getters setters Proxy Reflect property-descriptors

Sometimes we need more control over what happens when a property is read or written. Maybe we want to validate data before it’s stored, compute a value on the fly, or log every property access. JavaScript gives us three tools for this: getters/setters, Object.defineProperty, and Proxy.

Getters and Setters in Object Literals

A getter runs when we read a property. A setter runs when we assign to it. They look like regular properties from the outside, but they’re actually function calls under the hood.

const user = {
  firstName: "Manish",
  lastName: "Prajapati",
  get fullName() {
    return `${this.firstName} ${this.lastName}`; // computed on access
  },
  set fullName(value) {
    const [first, last] = value.split(" ");
    this.firstName = first;
    this.lastName = last;
  }
};

console.log(user.fullName);       // "Manish Prajapati" (calls getter)
user.fullName = "Tony Stark";     // calls setter
console.log(user.firstName);      // "Tony"

We access fullName like a normal property — no parentheses needed. That’s the whole point: it feels like data, but it’s actually logic.

Getters and Setters in Classes

Same idea, but inside a class:

class Temperature {
  #celsius; // private field

  constructor(celsius) {
    this.#celsius = celsius;
  }
  get fahrenheit() {
    return this.#celsius * 9 / 5 + 32;
  }
  set fahrenheit(f) {
    this.#celsius = (f - 32) * 5 / 9;
  }
}

const temp = new Temperature(100);
console.log(temp.fahrenheit);  // 212 (computed from celsius)
temp.fahrenheit = 32;          // sets celsius to 0

This is great for validation too — we can check values in the setter and throw if they’re invalid.

Object.defineProperty

For more control, Object.defineProperty lets us add getters/setters to existing objects and configure whether properties are enumerable, writable, etc.

const account = { _balance: 0 };

Object.defineProperty(account, "balance", {
  get() { return this._balance; },
  set(value) {
    if (value < 0) throw new Error("Balance can't be negative");
    this._balance = value;
  },
  enumerable: true
});

account.balance = 100;  // works
// account.balance = -50; // Error: Balance can't be negative

This is lower-level than class syntax, but useful when we need to add accessors dynamically or configure property descriptors.

Enter the Proxy

Proxy is a whole different level. It wraps an entire object and intercepts any operation on it — not just specific properties. Think of it as a security guard that sits between the outside world and our object.

const user = { name: "Manish", age: 25 };

const proxy = new Proxy(user, {
  get(target, prop) {
    console.log(`Reading ${prop}`);
    return target[prop];
  },
  set(target, prop, value) {
    console.log(`Setting ${prop} to ${value}`);
    target[prop] = value;
    return true; // must return true for success
  }
});

proxy.name;           // logs: Reading name → "Manish"
proxy.age = 26;       // logs: Setting age to 26

A Proxy takes two arguments: the target (the original object) and a handler (an object with “trap” functions that intercept operations).

Common Proxy Traps

Here are the traps we’ll use most often:

TrapInterceptsExample Use
getReading a propertyDefault values, logging
setWriting a propertyValidation, reactive updates
hasThe in operatorHide certain properties
deletePropertyThe delete operatorPrevent deletion
const schema = {
  name: "string",
  age: "number"
};

const validated = new Proxy({}, {
  set(target, prop, value) {
    if (schema[prop] && typeof value !== schema[prop]) {
      throw new TypeError(`${prop} must be a ${schema[prop]}`);
    }
    target[prop] = value;
    return true;
  }
});

validated.name = "Manish"; // works
validated.age = 25;        // works
// validated.age = "old";  // TypeError: age must be a number

The Reflect API

Reflect provides default behavior for all the proxy traps. Instead of doing target[prop] ourselves, we use Reflect.get(target, prop). This matters because Reflect methods handle edge cases (like getters with the right this) that manual access can miss.

const logged = new Proxy({}, {
  get(target, prop, receiver) {
    console.log(`Accessed: ${String(prop)}`);
    return Reflect.get(target, prop, receiver); // proper default behavior
  },
  set(target, prop, value, receiver) {
    console.log(`Set: ${String(prop)} = ${value}`);
    return Reflect.set(target, prop, value, receiver);
  }
});

The rule of thumb: inside proxy traps, use Reflect methods instead of direct object operations.

Practical Use Cases

Reactive objects — This is how Vue 3’s reactivity works. A Proxy detects when data changes and triggers re-renders.

Auto-populating defaults:

const withDefaults = new Proxy({}, {
  get(target, prop) {
    if (!(prop in target)) {
      target[prop] = []; // auto-create empty array for missing keys
    }
    return target[prop];
  }
});

withDefaults.users.push("Manish"); // no need to initialize users first
console.log(withDefaults.users);   // ["Manish"]

Negative array indexing:

const arr = new Proxy([10, 20, 30], {
  get(target, prop) {
    const index = Number(prop);
    if (index < 0) return target[target.length + index]; // arr[-1] = last element
    return Reflect.get(target, prop);
  }
});

console.log(arr[-1]); // 30
console.log(arr[-2]); // 20

Proxy vs Getters/Setters

Getters/SettersProxy
ScopeSpecific properties onlyAll properties, including ones that don’t exist yet
PerformanceFaster (no indirection layer)Slight overhead
Use caseComputed values, simple validationDynamic behavior, meta-programming
SyntaxBuilt into classes/objectsWraps an existing object

Use getters/setters when we know exactly which properties need special behavior. Use Proxy when we need to intercept everything dynamically.

In simple language, getters and setters let us run code when a specific property is read or written — great for validation and computed values. Proxy goes further and intercepts every operation on an entire object, which is what powers frameworks like Vue 3 under the hood.

References


11

Mixins & Composition over Inheritance

advanced mixins composition delegation Object.assign fragile-base-class

We’ve all heard the advice: “favor composition over inheritance.” But what does that actually mean? And why do experienced developers keep saying it? Let’s break it down.

The Problem with Deep Inheritance

Inheritance creates a tight parent-child coupling. Change something in the parent and every child feels it. This is called the fragile base class problem.

class Animal {
  constructor(name) { this.name = name; }
  move() { console.log(`${this.name} moves`); }
}

class Bird extends Animal {
  fly() { console.log(`${this.name} flies`); }
}

class Penguin extends Bird {
  // Penguins can't fly! But they inherit fly() from Bird.
  // Now we have to override it to throw or do nothing. Awkward.
  fly() { throw new Error("Penguins can't fly!"); }
}

The hierarchy says “a Penguin IS a Bird” and “a Bird CAN fly.” But that’s not true for all birds. This is where inheritance breaks down — real-world things don’t fit neatly into tree structures.

Inheritance (rigid tree)
Animal
↓ extends
Bird (has fly)
↓ extends
Penguin inherits fly()
Composition (flexible mix)
canWalk
canFly
canSwim
↓ pick what you need
Eagle = walk + fly
Penguin = walk + swim

Mixins with Object.assign

A mixin is simply an object whose methods we copy onto another object or class. Think of it like adding abilities to a character in a game — pick and choose what fits.

const canFly = {
  fly() { console.log(`${this.name} is flying`); }
};

const canSwim = {
  swim() { console.log(`${this.name} is swimming`); }
};

class Duck {
  constructor(name) { this.name = name; }
}

Object.assign(Duck.prototype, canFly, canSwim); // mix in both abilities

const duck = new Duck("Donald");
duck.fly();  // Donald is flying
duck.swim(); // Donald is swimming

Object.assign copies the methods from the mixin objects onto the class prototype. Every Duck instance now has fly() and swim().

Functional Mixins

A more powerful approach is to use functions that take a class and return a new class with extra behavior. This lets the mixin have its own state and constructor logic.

const Serializable = (Base) => class extends Base {
  serialize() {
    return JSON.stringify(this);
  }
  static deserialize(json) {
    return Object.assign(new this(), JSON.parse(json));
  }
};

const Timestamped = (Base) => class extends Base {
  constructor(...args) {
    super(...args);
    this.createdAt = new Date();
  }
};

class User {
  constructor(name) { this.name = name; }
}

class TrackedUser extends Timestamped(Serializable(User)) {}

const user = new TrackedUser("Manish");
console.log(user.serialize());  // {"name":"Manish","createdAt":"..."}
console.log(user.createdAt);    // Date object

We stack the mixins by nesting them. Each one adds a layer. The order matters — Timestamped runs its constructor after Serializable, which runs after User.

The Diamond Problem

In languages with multiple inheritance (like C++), we can get the diamond problem: class D inherits from B and C, both of which inherit from A. Which version of A’s methods does D get? JavaScript avoids this entirely because it only has single inheritance with extends. Mixins are just method copying, not real inheritance chains — so there’s no diamond.

If two mixins define the same method name, the last one wins (since Object.assign overwrites).

Delegation Pattern

Delegation means forwarding method calls to another object instead of inheriting from it. The delegating object says “I don’t know how to do this — but I know someone who does.”

class Engine {
  start() { console.log("Engine started"); }
  stop() { console.log("Engine stopped"); }
}

class Car {
  constructor() {
    this.engine = new Engine(); // Car HAS an engine (not IS an engine)
  }
  start() { this.engine.start(); } // delegate to engine
  stop() { this.engine.stop(); }
}

const car = new Car();
car.start(); // Engine started

The car doesn’t inherit from the engine. It owns an engine and delegates work to it. This is composition — building objects from parts rather than building class hierarchies.

Composition with Closures

We can compose behavior using plain functions and closures, no classes needed:

function createPlayer(name) {
  const state = { name, hp: 100 };

  return {
    getName: () => state.name,
    getHP: () => state.hp,
    takeDamage: (dmg) => { state.hp = Math.max(0, state.hp - dmg); },
    heal: (amount) => { state.hp = Math.min(100, state.hp + amount); }
  };
}

const player = createPlayer("Manish");
player.takeDamage(30);
console.log(player.getHP()); // 70

No this, no prototype, no class — just functions and data. The state is completely private thanks to the closure.

Real-World Example: Event Emitter Mixin

Here’s a practical mixin that adds event emitter behavior to any class:

const EventEmitter = (Base) => class extends Base {
  #listeners = {};

  on(event, fn) {
    (this.#listeners[event] ??= []).push(fn);
  }
  off(event, fn) {
    this.#listeners[event] = (this.#listeners[event] || []).filter(cb => cb !== fn);
  }
  emit(event, ...args) {
    (this.#listeners[event] || []).forEach(fn => fn(...args));
  }
};

class Store extends EventEmitter(class {}) {
  #data = {};
  set(key, value) {
    this.#data[key] = value;
    this.emit("change", { key, value });
  }
}

const store = new Store();
store.on("change", (e) => console.log(`${e.key} changed to ${e.value}`));
store.set("theme", "dark"); // "theme changed to dark"

Any class can gain event emitter powers by mixing in EventEmitter. No inheritance hierarchy needed.

In simple language, inheritance says “this thing IS a type of that thing” while composition says “this thing HAS these abilities.” Composition is more flexible because we can mix and match behaviors without getting locked into a rigid class tree. Use inheritance for true “is-a” relationships and composition for everything else.


12

Symbols & Well-Known OOP Protocols

advanced Symbol Symbol.iterator Symbol.toPrimitive Symbol.hasInstance iterable

Symbols are a primitive type added in ES6. Each Symbol is guaranteed to be unique — even if two Symbols have the same description. But the real OOP power comes from well-known Symbols — special built-in Symbols that let us hook into JavaScript’s internal behavior.

What Is a Symbol?

A Symbol is a unique, immutable identifier. Unlike strings, two Symbols are never equal.

const a = Symbol("id");
const b = Symbol("id");
console.log(a === b); // false — each Symbol is unique

// We use Symbols as property keys
const user = { [a]: 123 };
console.log(user[a]); // 123

Symbols don’t show up in for...in loops or Object.keys(), making them perfect for “hidden” metadata on objects.

Why Well-Known Symbols Matter for OOP

JavaScript has a set of built-in Symbols (like Symbol.iterator, Symbol.toPrimitive) that act as hooks. When we define methods with these Symbol keys on our objects, we’re telling JavaScript how our object should behave with built-in operations like for...of, string conversion, and instanceof.

In simple language, well-known Symbols let our custom objects “speak JavaScript’s language.”

Symbol.iterator — Making Objects Iterable

When we use for...of on an array, JavaScript calls array[Symbol.iterator]() behind the scenes. We can define this on our own objects to make them work with for...of, spread syntax, and destructuring.

An iterator is an object with a next() method that returns { value, done }.

class Range {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }
  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;
    return {
      next() {
        return current <= end
          ? { value: current++, done: false }
          : { done: true };
      }
    };
  }
}

const range = new Range(1, 5);
console.log([...range]);       // [1, 2, 3, 4, 5]
for (const n of range) { }     // works!
const [a, b] = new Range(10, 20); // a = 10, b = 11

Generators as a Shortcut

Writing iterators manually is verbose. Generators make it much cleaner. A generator function (with function*) automatically returns an iterator.

class Fibonacci {
  [Symbol.iterator]() {
    return this.generate();
  }
  *generate() {
    let [a, b] = [0, 1];
    while (true) {
      yield a;       // pauses here, returns value
      [a, b] = [b, a + b];
    }
  }
}

const first10 = [...new Fibonacci()].slice(0, 10); // won't work — infinite!

// Instead, take manually:
const fib = new Fibonacci();
const iter = fib[Symbol.iterator]();
for (let i = 0; i < 10; i++) {
  console.log(iter.next().value); // 0, 1, 1, 2, 3, 5, 8, 13, 21, 34
}

We can also use *[Symbol.iterator]() directly in a class to combine both in one:

class EvenNumbers {
  constructor(limit) { this.limit = limit; }
  *[Symbol.iterator]() {
    for (let i = 0; i <= this.limit; i += 2) yield i;
  }
}

console.log([...new EvenNumbers(10)]); // [0, 2, 4, 6, 8, 10]

Symbol.toPrimitive — Controlling Type Conversion

When JavaScript needs to convert our object to a primitive (number, string, or default), it calls [Symbol.toPrimitive](hint). The hint tells us what type is expected.

class Money {
  constructor(amount, currency) {
    this.amount = amount;
    this.currency = currency;
  }
  [Symbol.toPrimitive](hint) {
    if (hint === "number") return this.amount;
    if (hint === "string") return `${this.amount} ${this.currency}`;
    return this.amount; // default
  }
}

const price = new Money(42, "USD");
console.log(`Price: ${price}`);  // "Price: 42 USD" (hint = string)
console.log(+price);             // 42 (hint = number)
console.log(price + 10);         // 52 (hint = default)

Without this, JavaScript would just give us "[object Object]" for string conversion. Now our object converts meaningfully.

Symbol.hasInstance — Customizing instanceof

instanceof calls [Symbol.hasInstance] on the right-hand side. We can override it to change what instanceof means for our class.

class EvenNumber {
  static [Symbol.hasInstance](num) {
    return typeof num === "number" && num % 2 === 0;
  }
}

console.log(4 instanceof EvenNumber);   // true
console.log(7 instanceof EvenNumber);   // false
console.log("hi" instanceof EvenNumber); // false

This is unusual — most of the time we don’t need to override instanceof. But it’s good to know it’s possible.

Symbol.species — Controlling Derived Constructors

When we extend Array or Promise, methods like .map() and .then() need to create new instances. Symbol.species tells them which constructor to use.

class FilteredArray extends Array {
  static get [Symbol.species]() {
    return Array; // .map() returns a plain Array, not FilteredArray
  }
}

const filtered = new FilteredArray(1, 2, 3);
const doubled = filtered.map(x => x * 2);

console.log(doubled instanceof FilteredArray); // false
console.log(doubled instanceof Array);         // true

This is handy when our subclass has expensive constructors or special initialization that shouldn’t run for every intermediate operation.

Symbol.toStringTag

When we call Object.prototype.toString.call(something), it returns "[object Type]". We can control Type with Symbol.toStringTag.

class Database {
  get [Symbol.toStringTag]() {
    return "Database";
  }
}

const db = new Database();
console.log(Object.prototype.toString.call(db)); // [object Database]

This is useful for debugging — we can give our custom classes meaningful type tags instead of the generic "[object Object]".

Quick Reference

SymbolWhat It ControlsTriggered By
Symbol.iteratorIteration protocolfor...of, spread, destructuring
Symbol.toPrimitiveType conversion+obj, ${obj}, comparisons
Symbol.hasInstanceinstanceof checkx instanceof MyClass
Symbol.speciesDerived constructors.map(), .filter(), .then()
Symbol.toStringTagString representationObject.prototype.toString.call()

In simple language, well-known Symbols are hooks that let our custom objects plug into JavaScript’s built-in operations. The most practically useful one is Symbol.iterator — it lets any object work with for...of and spread syntax, which comes up all the time.

References


13

SOLID Principles in JavaScript

advanced SOLID SRP OCP LSP ISP DIP design-principles

SOLID is a set of five design principles that help us write code that’s easier to maintain, extend, and test. They were originally coined for OOP in languages like Java, but they apply just as well to JavaScript — we just use idiomatic JS patterns like callbacks, modules, and mixins instead of formal interfaces.

Let’s go through each one with a bad example, then the fixed version.

S — Single Responsibility Principle

One class (or module) should have only one reason to change.

If a class does too many things, changing one part risks breaking the others.

Bad — one class does everything:

class UserService {
  createUser(data) { /* save to DB */ }
  sendWelcomeEmail(user) { /* send email */ }
  generateReport(user) { /* create PDF */ }
}

This class has three reasons to change: DB logic, email logic, and report logic.

Good — split by responsibility:

class UserRepository {
  create(data) { /* save to DB */ }
}

class EmailService {
  sendWelcome(user) { /* send email */ }
}

class ReportGenerator {
  generate(user) { /* create PDF */ }
}

Now each class has one job. If the email provider changes, only EmailService changes.

O — Open/Closed Principle

Open for extension, closed for modification.

We should be able to add new behavior without modifying existing code. In JS, we do this with strategy patterns, plugins, or callbacks.

Bad — modifying existing code for every new shape:

class AreaCalculator {
  calculate(shape) {
    if (shape.type === "circle") return Math.PI * shape.radius ** 2;
    if (shape.type === "rectangle") return shape.width * shape.height;
    // Every new shape = modify this function
  }
}

Good — each shape knows how to calculate its own area:

class Circle {
  constructor(radius) { this.radius = radius; }
  area() { return Math.PI * this.radius ** 2; }
}

class Rectangle {
  constructor(w, h) { this.width = w; this.height = h; }
  area() { return this.width * this.height; }
}

// Adding a Triangle doesn't touch Circle or Rectangle
function totalArea(shapes) {
  return shapes.reduce((sum, s) => sum + s.area(), 0);
}

New shapes just implement area(). The totalArea function never changes.

L — Liskov Substitution Principle

Subclasses must be substitutable for their parent class without breaking anything.

If we have code that works with a parent class, swapping in a child class should not cause surprises.

Bad — the subclass breaks the parent’s contract:

class Bird {
  fly() { return "flying"; }
}

class Penguin extends Bird {
  fly() { throw new Error("Can't fly!"); } // breaks the contract!
}

function makeBirdFly(bird) {
  return bird.fly(); // crashes if we pass a Penguin
}

Good — restructure so the contract holds:

class Bird {
  move() { return "moving"; }
}

class FlyingBird extends Bird {
  fly() { return "flying"; }
}

class Penguin extends Bird {
  swim() { return "swimming"; }
}

// Now we only call fly() on FlyingBird, not all Birds

The rule is simple: if a subclass can’t do everything the parent promises, it shouldn’t extend that parent.

I — Interface Segregation Principle

Don’t force classes to implement methods they don’t need.

JavaScript doesn’t have formal interfaces, but the idea still applies. Instead of one giant mixin or base class with everything, we use small focused mixins.

Bad — one fat interface:

class Animal {
  fly() { throw new Error("Not implemented"); }
  swim() { throw new Error("Not implemented"); }
  walk() { throw new Error("Not implemented"); }
}

class Dog extends Animal {
  walk() { return "walking"; }
  // Forced to inherit fly() and swim() which make no sense
}

Good — small, role-based mixins:

const Walkable = (Base) => class extends Base {
  walk() { return `${this.name} is walking`; }
};

const Swimmable = (Base) => class extends Base {
  swim() { return `${this.name} is swimming`; }
};

class Dog extends Walkable(Swimmable(class {
  constructor(name) { this.name = name; }
})) {}

const dog = new Dog("Buddy");
dog.walk(); // "Buddy is walking"
dog.swim(); // "Buddy is swimming"
// No fly() — Dog doesn't need it

Each mixin adds only what’s relevant. Classes pick only the abilities they need.

D — Dependency Inversion Principle

Depend on abstractions, not concretions.

High-level modules shouldn’t depend directly on low-level modules. Both should depend on abstractions (callbacks, interfaces, injection).

Bad — tightly coupled to a specific database:

class UserService {
  constructor() {
    this.db = new PostgresDB(); // hardcoded dependency
  }
  getUser(id) {
    return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
  }
}

If we want to switch to MongoDB or use a mock in tests, we have to change UserService.

Good — inject the dependency:

class UserService {
  constructor(db) {
    this.db = db; // accepts any object with a query() method
  }
  getUser(id) {
    return this.db.findById("users", id);
  }
}

// Production
const service = new UserService(new PostgresDB());

// Tests
const service = new UserService(new MockDB());

Now UserService doesn’t care what database it’s using. It just needs something with a findById() method. In JS, we don’t need a formal interface — we rely on duck typing (“if it has the right methods, it works”).

SOLID at a Glance

PrincipleOne-LinerJS Pattern
S — Single ResponsibilityOne class = one jobSmall modules, separate concerns
O — Open/ClosedExtend, don’t modifyStrategy pattern, plugins, callbacks
L — Liskov SubstitutionSubclasses keep promisesDon’t override to break contracts
I — Interface SegregationSmall, focused interfacesRole-based mixins
D — Dependency InversionDepend on abstractionsConstructor injection, callbacks

In simple language, SOLID principles are guidelines, not strict rules. We don’t need to apply all five everywhere — that’s overengineering. But knowing them helps us recognize when a class is doing too much, when inheritance is the wrong tool, or when our code is too tightly coupled. Use them as a compass, not a rulebook.


14

OOP Design Patterns in JavaScript

advanced design-patterns singleton factory observer strategy decorator

Design patterns are proven solutions to problems that keep showing up in software. We don’t need to memorize all 23 GoF patterns, but knowing the most common ones helps us write cleaner code and recognize them in frameworks we already use. Let’s look at seven patterns that come up most in JavaScript.

Singleton — One Instance, Ever

A Singleton ensures only one instance of a class exists. Useful for things like a config manager, logger, or database connection pool.

In modern JS, the simplest Singleton is just a module-level instance — since ES modules are cached, the same instance is returned every time.

// logger.js
class Logger {
  #logs = [];
  log(msg) {
    this.#logs.push({ msg, time: Date.now() });
    console.log(`[LOG] ${msg}`);
  }
  getHistory() { return [...this.#logs]; }
}

export const logger = new Logger(); // single instance, module-cached

Anyone who imports logger gets the exact same object. No need for getInstance() gymnastics — the module system handles it.

If we need a class-based approach (for interviews):

class AppConfig {
  static #instance;
  #settings = {};

  static getInstance() {
    if (!AppConfig.#instance) AppConfig.#instance = new AppConfig();
    return AppConfig.#instance;
  }
  set(key, val) { this.#settings[key] = val; }
  get(key) { return this.#settings[key]; }
}

const a = AppConfig.getInstance();
const b = AppConfig.getInstance();
console.log(a === b); // true

Factory — Create Without Specifying the Exact Class

A Factory function creates objects without the caller knowing the exact class or construction details. The caller says what they want, and the factory figures out how to build it.

class PostgresDB {
  query(sql) { console.log(`Postgres: ${sql}`); }
}
class MongoDB {
  query(sql) { console.log(`Mongo: ${sql}`); }
}

function createDatabase(type) {
  if (type === "postgres") return new PostgresDB();
  if (type === "mongo") return new MongoDB();
  throw new Error(`Unknown database: ${type}`);
}

const db = createDatabase("postgres");
db.query("SELECT * FROM users"); // Postgres: SELECT * FROM users

The caller doesn’t import or know about PostgresDB or MongoDB directly. If we add MySQL later, only the factory changes. React.createElement() is a real-world factory.

Builder — Step-by-Step Construction

The Builder pattern builds complex objects step by step, usually with a fluent API (method chaining). Great when an object has many optional configurations.

class QueryBuilder {
  #table = "";
  #conditions = [];
  #limit = null;

  from(table) { this.#table = table; return this; }
  where(condition) { this.#conditions.push(condition); return this; }
  limit(n) { this.#limit = n; return this; }
  build() {
    let sql = `SELECT * FROM ${this.#table}`;
    if (this.#conditions.length) sql += ` WHERE ${this.#conditions.join(" AND ")}`;
    if (this.#limit) sql += ` LIMIT ${this.#limit}`;
    return sql;
  }
}

const query = new QueryBuilder()
  .from("users")
  .where("age > 18")
  .where("active = true")
  .limit(10)
  .build();
// "SELECT * FROM users WHERE age > 18 AND active = true LIMIT 10"

Each method returns this so we can chain calls. The build() at the end produces the final result. Express middleware and Knex.js query builder use this pattern.

Observer — Subscribe to Changes

The Observer (or Pub/Sub) pattern lets objects subscribe to events and get notified when something happens. One object emits events, many objects listen.

class EventBus {
  #listeners = new Map();

  on(event, callback) {
    if (!this.#listeners.has(event)) this.#listeners.set(event, []);
    this.#listeners.get(event).push(callback);
  }
  off(event, callback) {
    const cbs = this.#listeners.get(event) || [];
    this.#listeners.set(event, cbs.filter(cb => cb !== callback));
  }
  emit(event, data) {
    (this.#listeners.get(event) || []).forEach(cb => cb(data));
  }
}

const bus = new EventBus();
bus.on("user:login", (user) => console.log(`${user} logged in`));
bus.emit("user:login", "Manish"); // "Manish logged in"

This is everywhere: addEventListener in the DOM, Node’s EventEmitter, Redux subscriptions, and Vue/React state management all use Observer under the hood.

Strategy — Swap Algorithms at Runtime

The Strategy pattern lets us define a family of algorithms and swap them out without changing the code that uses them. In JS, we just pass functions around — first-class functions make this natural.

// Define strategies as simple functions
const strategies = {
  standard: (amount) => amount * 0.1,     // 10% tax
  reduced: (amount) => amount * 0.05,     // 5% tax
  exempt: () => 0                          // no tax
};

function calculateTax(amount, type = "standard") {
  const strategy = strategies[type];
  if (!strategy) throw new Error(`Unknown tax type: ${type}`);
  return strategy(amount);
}

console.log(calculateTax(100, "standard")); // 10
console.log(calculateTax(100, "reduced"));  // 5
console.log(calculateTax(100, "exempt"));   // 0

Adding a new tax type is just adding a new key to the strategies object. No if/else chains, no modifying existing code. This also ties directly to the Open/Closed principle from SOLID.

Decorator — Wrap to Add Behavior

The Decorator pattern wraps an object to add extra behavior without modifying the original. In JS, higher-order functions are the natural way to do this.

function withLogging(fn) {
  return function (...args) {
    console.log(`Calling ${fn.name} with`, args);
    const result = fn(...args);
    console.log(`Result:`, result);
    return result;
  };
}

function add(a, b) { return a + b; }

const loggedAdd = withLogging(add);
loggedAdd(2, 3);
// Calling add with [2, 3]
// Result: 5

We can also decorate class instances:

function withTimestamp(instance) {
  const original = instance.save.bind(instance);
  instance.save = function (data) {
    return original({ ...data, updatedAt: new Date() });
  };
  return instance;
}

Express middleware is essentially a decorator chain — each middleware wraps the request/response to add behavior (logging, auth, CORS) before passing it along.

Proxy Pattern — Control Access

The Proxy pattern controls access to an object. JavaScript has this built into the language with the Proxy API, but the pattern itself is about adding a layer between the caller and the real object.

function createCachedAPI(apiClient) {
  const cache = new Map();

  return new Proxy(apiClient, {
    get(target, prop) {
      if (prop === "fetch") {
        return async (url) => {
          if (cache.has(url)) {
            console.log("Cache hit");
            return cache.get(url);
          }
          const result = await target.fetch(url);
          cache.set(url, result);
          return result;
        };
      }
      return Reflect.get(target, prop);
    }
  });
}

The proxy sits between the caller and the API client, transparently adding caching. The caller doesn’t know (or care) that caching is happening.

Quick Reference

PatternCategoryCore IdeaReal-World Example
SingletonCreationalOne instance globallyModule exports, Redux store
FactoryCreationalCreate without specifying classReact.createElement, express()
BuilderCreationalStep-by-step fluent constructionKnex.js, query builders
ObserverBehavioralSubscribe to eventsaddEventListener, EventEmitter
StrategyBehavioralSwap algorithms via functionsSorting comparators, tax rules
DecoratorStructuralWrap to add behaviorExpress middleware, HOCs
ProxyStructuralControl access to an objectCaching, validation, Vue reactivity

In simple language, design patterns aren’t rules we have to follow — they’re names for solutions we’ve probably already used without knowing it. Recognizing them helps us communicate with other developers and pick the right tool when we face a familiar problem.


Python

15

Classes & Instances

beginner classes objects self __init__ attributes

A class is a blueprint for creating objects. Think of it like a cookie cutter — the class defines the shape, and every cookie we stamp out is an instance (an actual object in memory).

The class Keyword

We define a class with the class keyword. By convention, class names use PascalCase.

class Dog:
    species = "Canis familiaris"  # class attribute — shared by ALL dogs

    def __init__(self, name, age):
        self.name = name  # instance attribute — unique to each dog
        self.age = age

What Is __init__?

__init__ is the initializer, not the constructor. The actual constructor is __new__ (which creates the object). __init__ just sets up the initial state after the object already exists.

In simple language, __init__ is where we say “okay, this new object should have these attributes with these values.”

What Is self?

Every instance method receives self as its first argument. It’s a reference to the current instance. In simple language, self is how the object talks about itself — “my name”, “my age”.

Python passes self automatically — we never need to pass it ourselves.

class Dog:
    def __init__(self, name):
        self.name = name  # "my name is whatever was passed in"

    def bark(self):
        return f"{self.name} says Woof!"  # "my name says Woof!"

buddy = Dog("Buddy")
print(buddy.bark())  # Buddy says Woof!

Creating Instances

We call the class like a function. Python creates the object with __new__, then runs __init__ on it.

buddy = Dog("Buddy")
rex = Dog("Rex")

print(type(buddy))           # <class '__main__.Dog'>
print(isinstance(buddy, Dog))  # True

Instance vs Class Attributes

  • Class attributes live on the class itself. Every instance shares them.
  • Instance attributes live on each individual object. Each instance gets its own copy.
class Dog:
    species = "Canis familiaris"  # class attribute

    def __init__(self, name):
        self.name = name  # instance attribute

buddy = Dog("Buddy")
rex = Dog("Rex")

print(buddy.species)  # Canis familiaris — found on the class
print(rex.species)    # Canis familiaris — same shared value

Attribute Lookup Order

When we access obj.attr, Python looks in this order:

  1. The instance __dict__ (instance attributes)
  2. The class __dict__ (class attributes and methods)
  3. Parent classes (following the MRO)

If nothing is found anywhere, we get an AttributeError.

Attribute Lookup Order
1. Instance __dict__
buddy.__dict__ = {'name': 'Buddy'}
found? return
not found ↓
2. Class __dict__
Dog.__dict__ = {'species': 'Canis familiaris', ...}
found? return
not found ↓
3. Parent Classes (MRO)
Walks up the inheritance chain
found? return
not found ↓ AttributeError

Shadowing Class Attributes

When we assign to an instance, we create a new instance attribute that shadows the class one. The class attribute stays unchanged for everyone else.

buddy.species = "Robot Dog"  # creates instance attribute
print(buddy.species)  # Robot Dog — instance wins
print(rex.species)    # Canis familiaris — class attribute untouched

Peeking Inside: __dict__

Every object has a __dict__ that holds its attributes as a dictionary. This is super useful for debugging.

print(buddy.__dict__)  # {'name': 'Buddy', 'species': 'Robot Dog'}
print(Dog.__dict__)    # {'species': 'Canis familiaris', '__init__': ..., ...}

type() and isinstance()

  • type(obj) tells us the exact class of an object.
  • isinstance(obj, cls) checks if an object is an instance of a class or any of its subclasses. This is almost always what we want.
print(type(buddy))               # <class '__main__.Dog'>
print(type(buddy) == Dog)        # True
print(isinstance(buddy, Dog))    # True
print(isinstance(buddy, object)) # True — everything inherits from object

In simple language, a class is a template, and an instance is a real object built from that template. Python looks for attributes on the instance first, then the class, then parent classes. That lookup order is the key to understanding everything else in Python OOP.


16

Method Types: Instance, Class & Static

beginner methods staticmethod classmethod self cls

Python classes have three kinds of methods. The only difference between them is what they receive as the first argument — and that tells us a lot about when to use each.

Instance Methods (Regular Methods)

These are the most common. They take self as the first argument, which gives them access to the instance and all its attributes.

class Pizza:
    def __init__(self, size, toppings):
        self.size = size
        self.toppings = toppings

    def describe(self):  # instance method — needs self
        return f"{self.size} pizza with {', '.join(self.toppings)}"

p = Pizza("Large", ["mushrooms", "olives"])
print(p.describe())  # Large pizza with mushrooms, olives

We use instance methods whenever the logic needs to read or modify the object’s state.

Class Methods (@classmethod)

These take cls as the first argument instead of self. They receive the class itself, not an instance. The most common use case is alternative constructors (factory methods).

class Pizza:
    def __init__(self, size, toppings):
        self.size = size
        self.toppings = toppings

    @classmethod
    def margherita(cls, size):  # cls = Pizza (or a subclass)
        return cls(size, ["mozzarella", "basil", "tomato"])

p = Pizza.margherita("Medium")  # alternative way to create a Pizza
print(p.toppings)  # ['mozzarella', 'basil', 'tomato']

The beauty of cls is that it respects inheritance. If a subclass calls margherita(), cls will be the subclass, not Pizza.

Static Methods (@staticmethod)

These don’t receive self or cls. They’re just regular functions that live inside the class for organizational purposes.

class Pizza:
    @staticmethod
    def validate_topping(topping):  # no self, no cls
        valid = ["mushrooms", "olives", "pepperoni", "mozzarella"]
        return topping.lower() in valid

print(Pizza.validate_topping("Olives"))  # True

We use static methods for utility logic that’s related to the class but doesn’t need access to any instance or class state.

Side-by-Side Comparison

Three Method Types
Instance Method
First arg: self
Access: instance + class
Use: most business logic
@classmethod
First arg: cls
Access: class only
Use: factory methods
@staticmethod
First arg: none
Access: nothing
Use: utility helpers
Rule of thumb: start with instance methods. Use classmethod for alt constructors. Use staticmethod for pure helpers.

Why @classmethod Matters for Inheritance

Here’s the real power. When we subclass, cls points to the subclass, so factory methods automatically return the correct type.

class Pizza:
    def __init__(self, size, toppings):
        self.size = size
        self.toppings = toppings

    @classmethod
    def margherita(cls, size):
        return cls(size, ["mozzarella", "basil"])  # cls = whatever subclass

class DeepDish(Pizza):
    pass

dd = DeepDish.margherita("Large")
print(type(dd))  # <class 'DeepDish'> — not Pizza!

If we’d used a @staticmethod and hardcoded Pizza(...), the subclass would have gotten a Pizza back instead of a DeepDish. That’s a subtle but important difference.

The Common Interview Question

“What’s the difference between @staticmethod and @classmethod?”

The short answer: @classmethod receives the class as cls and can create instances or access class-level state. @staticmethod receives nothing extra — it’s just a plain function living inside the class namespace. If the method doesn’t need access to the class or instance at all, use @staticmethod. If it needs the class (especially for creating objects), use @classmethod.

Calling Convention

All three can be called on the class or an instance, but the conventions are:

Pizza.validate_topping("olives")   # static — usually called on class
Pizza.margherita("Small")          # classmethod — usually called on class
p = Pizza("Large", ["olives"])
p.describe()                       # instance method — called on instance

In simple language, the three method types differ by what they get as a first argument: self (the object), cls (the class), or nothing. Pick the one that matches what information the method actually needs.


17

Inheritance & MRO

intermediate inheritance super MRO diamond-problem C3-linearization

Inheritance lets one class reuse the code of another. The child class (subclass) inherits all methods and attributes from the parent class (superclass) and can override or extend them.

Single Inheritance

The simplest form. We put the parent class in parentheses.

class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return f"{self.name} makes a sound"

class Dog(Animal):  # Dog inherits from Animal
    def speak(self):  # override parent method
        return f"{self.name} says Woof!"

d = Dog("Buddy")
print(d.speak())  # Buddy says Woof!
print(d.name)     # Buddy — inherited from Animal

Why super()?

super() gives us a reference to the parent class. We use it to call the parent’s methods without hardcoding the parent class name.

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)  # call Animal.__init__
        self.breed = breed      # add new attribute

d = Dog("Buddy", "Labrador")
print(d.name, d.breed)  # Buddy Labrador

Without super(), we’d have to write Animal.__init__(self, name) — which breaks if we rename the parent or change the hierarchy.

Multiple Inheritance

Python supports inheriting from more than one class. This is where things get interesting.

class Flyer:
    def fly(self):
        return "I can fly!"

class Swimmer:
    def swim(self):
        return "I can swim!"

class Duck(Flyer, Swimmer):  # inherits from both
    pass

d = Duck()
print(d.fly())   # I can fly!
print(d.swim())  # I can swim!

The Diamond Problem

The diamond problem happens when a class inherits from two classes that share a common ancestor. Which version of the shared method should we get?

The Diamond Problem
A (object)
def greet(self)
B(A)
def greet(self)
C(A)
def greet(self)
D(B, C)
Which greet() runs?
MRO for D: D → B → C → A → object

C3 Linearization (MRO)

Python solves the diamond problem using an algorithm called C3 linearization. It produces the Method Resolution Order (MRO) — a flat list that determines the exact order Python searches for methods.

We can inspect it with .mro() or __mro__:

class A:
    def greet(self): return "A"

class B(A):
    def greet(self): return "B"

class C(A):
    def greet(self): return "C"

class D(B, C):
    pass

print(D.mro())  # [D, B, C, A, object]
d = D()
print(d.greet())  # "B" — B comes first in the MRO

The key rules of C3 linearization:

  1. Children always come before parents.
  2. The order of parents in class D(B, C) is preserved (B before C).
  3. Each class appears only once.

How super() Works with MRO

Here’s the part that trips people up. super() doesn’t just call the parent class — it calls the next class in the MRO. This is crucial for cooperative multiple inheritance.

class A:
    def greet(self):
        print("A")

class B(A):
    def greet(self):
        print("B")
        super().greet()  # next in MRO, not necessarily A

class C(A):
    def greet(self):
        print("C")
        super().greet()  # next in MRO

class D(B, C):
    def greet(self):
        print("D")
        super().greet()  # next in MRO

D().greet()  # D → B → C → A (follows MRO exactly)

Cooperative Multiple Inheritance

For multiple inheritance to work cleanly, every class in the hierarchy should call super(). This is called “cooperative” because each class cooperates by passing control along the MRO chain.

class Base:
    def __init__(self, **kwargs):
        pass  # end of the chain — absorb leftover kwargs

class Named(Base):
    def __init__(self, name, **kwargs):
        super().__init__(**kwargs)
        self.name = name

class Aged(Base):
    def __init__(self, age, **kwargs):
        super().__init__(**kwargs)
        self.age = age

class Person(Named, Aged):
    pass

p = Person(name="Manish", age=25)
print(p.name, p.age)  # Manish 25

isinstance() and issubclass()

print(isinstance(d, D))  # True
print(isinstance(d, A))  # True — D inherits from A
print(issubclass(D, A))  # True — D is a subclass of A
print(issubclass(B, C))  # False — B and C are siblings

In simple language, inheritance lets us reuse code by creating parent-child relationships between classes. When multiple inheritance creates ambiguity, Python uses C3 linearization to produce a clear method resolution order. And super() always follows that MRO, not just “the parent.”


18

Encapsulation & Name Mangling

intermediate encapsulation name-mangling private protected access-control

Python doesn’t have private or protected keywords like Java or C++. Instead, it relies on naming conventions and a gentle nudge called name mangling. The philosophy is “we’re all consenting adults here” — we trust developers to respect boundaries without the language forcing them.

The Three Levels

Python Access Levels
name
Public
Access from anywhere. No restrictions.
_name
Protected (by convention)
A signal that says "internal use, don't touch." Not enforced.
__name
Name-mangled
Python renames it to _ClassName__name. Harder to access accidentally.

Single Underscore _name — “Protected”

A single leading underscore is a convention. It tells other developers “this is an internal implementation detail, please don’t use it directly.” But Python does absolutely nothing to stop anyone.

class BankAccount:
    def __init__(self, balance):
        self._balance = balance  # "protected" — internal detail

    def deposit(self, amount):
        self._balance += amount

acc = BankAccount(100)
print(acc._balance)  # 100 — works fine, just frowned upon

The only place Python enforces single underscores: from module import * won’t import names starting with _.

Double Underscore __name — Name Mangling

A double leading underscore triggers name mangling. Python rewrites the attribute name to _ClassName__name to avoid accidental name collisions in subclasses.

class BankAccount:
    def __init__(self, balance):
        self.__balance = balance  # name-mangled

acc = BankAccount(100)
# print(acc.__balance)       # AttributeError!
print(acc._BankAccount__balance)  # 100 — the mangled name

In simple language, Python doesn’t hide __balance — it just renames it. We can still access it if we really want to, but we have to go out of our way.

Why Name Mangling Exists

Name mangling isn’t about security. It’s about preventing accidental overwrites in subclasses. Here’s the actual use case:

class Base:
    def __init__(self):
        self.__value = 10  # becomes _Base__value

class Child(Base):
    def __init__(self):
        super().__init__()
        self.__value = 20  # becomes _Child__value (different name!)

c = Child()
print(c._Base__value)   # 10 — Base's version is safe
print(c._Child__value)  # 20 — Child's version is separate

Without name mangling, Child.__value would have overwritten Base.__value. The mangling keeps them separate.

Dunder Methods Are NOT Mangled

A common misconception: names with double underscores on both sides (like __init__, __str__) are not mangled. Name mangling only applies to names with two or more leading underscores and at most one trailing underscore.

class Foo:
    def __init__(self):  # NOT mangled — dunder method
        self.__x = 1     # mangled to _Foo__x
        self.__y__ = 2   # NOT mangled — has trailing underscores

Using @property for Real Encapsulation

The Pythonic way to control access is through properties. We keep the attribute “private” and expose it through a clean interface.

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius  # single underscore convention

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Below absolute zero!")
        self._celsius = value

t = Temperature(25)
print(t.celsius)    # 25 — looks like attribute access
t.celsius = 30      # validation runs automatically
# t.celsius = -300  # ValueError: Below absolute zero!

This is the preferred approach. We get the clean syntax of attribute access (t.celsius) with the power of validation behind the scenes.

When to Use What

  • No underscore (self.name): public API, meant for external use.
  • Single underscore (self._name): internal detail. “Don’t use this unless you know what you’re doing.”
  • Double underscore (self.__name): use only when we specifically need to avoid name collisions in a deep inheritance hierarchy. It’s rare.
  • @property: when we need validation, computed values, or want to make an attribute read-only. This is the Pythonic encapsulation tool.

In simple language, Python trusts us to respect naming conventions instead of enforcing strict access control. Single underscore means “internal,” double underscore prevents accidental name clashes in subclasses, and @property is how we build real controlled access when we need it.


19

Polymorphism & Duck Typing

intermediate polymorphism duck-typing EAFP operator-overloading singledispatch

Polymorphism means “many forms.” In simple language, it means the same operation behaves differently depending on the object we’re working with. We call len() on a list, a string, or a dictionary — same function, different behavior. That’s polymorphism.

Method Overriding

The most basic form. A child class redefines a method from the parent, and Python uses the child’s version.

class Shape:
    def area(self):
        return 0

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):  # overrides Shape.area
        return 3.14159 * self.radius ** 2

class Rectangle(Shape):
    def __init__(self, w, h):
        self.w, self.h = w, h

    def area(self):  # overrides Shape.area
        return self.w * self.h

Now we can write code that works with any shape:

shapes = [Circle(5), Rectangle(4, 6)]
for s in shapes:
    print(s.area())  # each shape calculates its own area
# 78.53975
# 24

We don’t care what type s is. We just call .area() and each object handles it.

Duck Typing

“If it walks like a duck and quacks like a duck, then it must be a duck.”

Python doesn’t care about an object’s type. It cares about what the object can do. If an object has the method we’re calling, Python is happy — no inheritance required.

class Dog:
    def speak(self):
        return "Woof!"

class Cat:
    def speak(self):
        return "Meow!"

class Robot:
    def speak(self):
        return "Beep boop!"

# No shared parent class needed!
for thing in [Dog(), Cat(), Robot()]:
    print(thing.speak())

Dog, Cat, and Robot have zero relationship. But they all have .speak(), and that’s enough. This is duck typing in action.

EAFP vs LBYL

Python’s duck typing philosophy extends to error handling with two competing styles:

LBYL (Look Before You Leap) — check first, then act:

if hasattr(obj, "speak"):  # check first
    obj.speak()

EAFP (Easier to Ask Forgiveness than Permission) — just do it, handle errors if they happen:

try:
    obj.speak()  # just go for it
except AttributeError:
    print("This object can't speak")

EAFP is the Pythonic way. It’s usually faster too, because in the happy path we skip the check entirely.

Operator Overloading

Python lets us define what operators like +, *, ==, and < do for our objects. We do this by implementing special dunder methods.

class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __add__(self, other):       # v1 + v2
        return Vector(self.x + other.x, self.y + other.y)

    def __mul__(self, scalar):      # v * 3
        return Vector(self.x * scalar, self.y * scalar)

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2)    # Vector(4, 6)
print(v1 * 3)     # Vector(3, 6)

Common operator dunder methods: __add__ (+), __sub__ (-), __mul__ (*), __eq__ (==), __lt__ (<), __len__ (len()), __getitem__ ([]).

Python Has No Method Overloading

In languages like Java, we can define multiple methods with the same name but different parameter types. Python doesn’t support this — the last definition wins.

class Calculator:
    def add(self, a, b):
        return a + b

    def add(self, a, b, c):  # this REPLACES the previous add!
        return a + b + c

calc = Calculator()
# calc.add(1, 2)     # TypeError — the two-arg version is gone
print(calc.add(1, 2, 3))  # 6

Instead, we use default arguments or *args:

class Calculator:
    def add(self, *args):  # accept any number of arguments
        return sum(args)

calc = Calculator()
print(calc.add(1, 2))      # 3
print(calc.add(1, 2, 3))   # 6

functools.singledispatch for Type-Based Dispatch

If we genuinely need different behavior based on argument type, Python offers singledispatch. It works with regular functions (not methods).

from functools import singledispatch

@singledispatch
def process(data):
    raise TypeError(f"Unsupported type: {type(data)}")

@process.register(str)
def _(data):
    return data.upper()

@process.register(list)
def _(data):
    return [x * 2 for x in data]

print(process("hello"))    # HELLO
print(process([1, 2, 3]))  # [2, 4, 6]

For methods inside a class, Python 3.8+ has functools.singledispatchmethod.

In simple language, polymorphism means “same interface, different behavior.” Python achieves it through method overriding, duck typing, and operator overloading — and it prefers duck typing over rigid type checking, because what matters is what an object can do, not what it is.


20

Dunder Methods Deep Dive

intermediate dunder magic-methods data-model __str__ __repr__ __eq__ __add__

Dunder (double underscore) methods are how we hook into Python’s built-in behavior. When we write len(obj), Python actually calls obj.__len__(). When we write a + b, Python calls a.__add__(b). These methods are the data model — they let our objects play nice with Python’s syntax.

Representation: __str__ vs __repr__

These two control how our object shows up as text.

  • __repr__ — the “developer” version. Should be unambiguous. What the REPL shows.
  • __str__ — the “user” version. What print() uses. If missing, falls back to __repr__.

Rule of thumb: always implement __repr__. Only add __str__ if we want a prettier output.

class Money:
    def __init__(self, amount, currency="USD"):
        self.amount = amount
        self.currency = currency

    def __repr__(self):
        return f"Money({self.amount}, '{self.currency}')"

    def __str__(self):
        return f"${self.amount:.2f} {self.currency}"

m = Money(42.5)
print(repr(m))  # Money(42.5, 'USD') — unambiguous
print(m)        # $42.50 USD — pretty

There’s also __format__, which powers f-strings and format():

def __format__(self, spec):
    if spec == "short":
        return f"${self.amount:.0f}"
    return str(self)

Comparison: __eq__, __lt__, and Friends

By default, == compares object identity (same as is). We override __eq__ to compare by value instead.

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented  # let Python try other.__eq__
        return self.x == other.x and self.y == other.y

    def __lt__(self, other):
        return (self.x ** 2 + self.y ** 2) < (other.x ** 2 + other.y ** 2)

print(Point(1, 2) == Point(1, 2))  # True
print(Point(1, 2) < Point(3, 4))   # True — closer to origin

The __eq__/__hash__ contract: if we define __eq__, Python automatically sets __hash__ to None, making our objects unhashable (can’t be used in sets or as dict keys). If we need that, we must also define __hash__.

def __hash__(self):
    return hash((self.x, self.y))  # must be consistent with __eq__

Arithmetic: __add__, __radd__, __iadd__

Three flavors of addition alone:

  • __add__ — handles self + other
  • __radd__ — handles other + self (when other doesn’t know how)
  • __iadd__ — handles self += other
class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __radd__(self, other):
        if other == 0:  # needed for sum() to work
            return self
        return self.__add__(other)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

v = Vector(1, 2) + Vector(3, 4)
print(v)  # Vector(4, 6)

Container Protocol: __len__, __getitem__, __iter__

These let our object behave like a list or dictionary.

class Playlist:
    def __init__(self, songs):
        self._songs = list(songs)

    def __len__(self):
        return len(self._songs)

    def __getitem__(self, index):       # playlist[0], playlist[1:3]
        return self._songs[index]

    def __contains__(self, song):       # "Hey Jude" in playlist
        return song in self._songs

    def __iter__(self):                 # for song in playlist
        return iter(self._songs)

pl = Playlist(["Hey Jude", "Yesterday", "Let It Be"])
print(len(pl))           # 3
print(pl[0])             # Hey Jude
print("Yesterday" in pl) # True

With just __getitem__, Python can already iterate over our object. But adding __iter__ is more explicit and efficient.

Callable Objects: __call__

__call__ makes an instance callable like a function. This is great for objects that need to maintain state between calls.

class Counter:
    def __init__(self):
        self.count = 0

    def __call__(self):
        self.count += 1
        return self.count

counter = Counter()
print(counter())  # 1
print(counter())  # 2
print(counter())  # 3

Context Manager: __enter__ and __exit__

These power the with statement. __enter__ sets things up, __exit__ cleans up — even if an exception happens.

class Timer:
    def __enter__(self):
        import time
        self.start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        import time
        self.elapsed = time.perf_counter() - self.start
        print(f"Took {self.elapsed:.4f}s")
        return False  # don't suppress exceptions

with Timer():
    sum(range(1_000_000))  # Took 0.0234s

Boolean: __bool__

Controls what happens when we use our object in a boolean context (if obj:, bool(obj)).

class Bag:
    def __init__(self, items):
        self.items = items

    def __bool__(self):
        return len(self.items) > 0  # empty bag is falsy

bag = Bag([])
if not bag:
    print("Bag is empty!")  # this runs

If __bool__ isn’t defined, Python falls back to __len__. If neither exists, the object is always truthy.

In simple language, dunder methods are hooks into Python’s syntax. By implementing the right ones, our objects can work with +, ==, len(), for loops, with statements, and everything else Python offers. We’re customizing how Python treats our objects.


21

Property Decorators & Descriptors

intermediate property descriptors getter setter __get__ __set__

In Java, we write getX() and setX() methods everywhere. In Python, we use @property — it gives us the same control (validation, computed values, read-only attributes) but with clean attribute-style access. No getBalance() nonsense.

@property — Pythonic Getters

@property turns a method into something that looks like an attribute. We call it without parentheses.

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def area(self):  # accessed as circle.area, not circle.area()
        return 3.14159 * self._radius ** 2

c = Circle(5)
print(c.area)  # 78.53975 — looks like an attribute
# c.area = 10  # AttributeError — it's read-only by default

Adding a Setter with @attr.setter

If we want to allow assignment, we define a setter.

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius  # this calls the setter!

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Below absolute zero!")
        self._celsius = value

    @property
    def fahrenheit(self):  # computed, read-only
        return self._celsius * 9/5 + 32

t = Temperature(100)
print(t.fahrenheit)  # 212.0
t.celsius = -300     # ValueError: Below absolute zero!

Notice: self.celsius = celsius in __init__ calls the setter, so validation runs on creation too.

Adding a Deleter with @attr.deleter

Rarely needed, but we can control what happens when someone does del obj.attr.

@celsius.deleter
def celsius(self):
    print("Resetting temperature")
    self._celsius = 0

Read-Only Properties

Any @property without a setter is automatically read-only. This is the simplest way to make an attribute that can’t be changed from outside.

class User:
    def __init__(self, first, last):
        self._first = first
        self._last = last

    @property
    def full_name(self):  # computed, read-only
        return f"{self._first} {self._last}"

u = User("Manish", "Prajapati")
print(u.full_name)    # Manish Prajapati
# u.full_name = "X"   # AttributeError — no setter defined

How property() Works Under the Hood

@property is just syntactic sugar for the property() built-in. These two are identical:

# Using decorator syntax
class C:
    @property
    def x(self):
        return self._x

# Using property() directly — same thing
class C:
    def _get_x(self):
        return self._x
    x = property(_get_x)

And property() itself is a descriptor. Which brings us to…

The Descriptor Protocol

A descriptor is any object that defines __get__, __set__, or __delete__. When Python accesses an attribute, it checks if the value stored on the class is a descriptor. If so, it calls the descriptor’s methods instead of returning the value directly.

In simple language, a descriptor is an object that intercepts attribute access and does something custom with it.

class Validated:
    """A descriptor that enforces a minimum value."""
    def __init__(self, min_value=0):
        self.min_value = min_value

    def __set_name__(self, owner, name):
        self.name = name          # the attribute name on the owner class

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self           # accessed on class, return descriptor
        return obj.__dict__.get(self.name, 0)

    def __set__(self, obj, value):
        if value < self.min_value:
            raise ValueError(f"{self.name} must be >= {self.min_value}")
        obj.__dict__[self.name] = value

class Product:
    price = Validated(min_value=0)   # descriptor instance on the class
    quantity = Validated(min_value=1)

p = Product()
p.price = 9.99     # calls Validated.__set__
p.quantity = 5
# p.price = -1     # ValueError: price must be >= 0

Data vs Non-Data Descriptors

This distinction matters for attribute lookup:

  • Data descriptor: defines __set__ (or __delete__). Takes priority over instance __dict__.
  • Non-data descriptor: defines only __get__. Instance __dict__ takes priority.

property is a data descriptor (it has __set__). Regular methods are non-data descriptors (they only have __get__).

The lookup order is: data descriptors > instance dict > non-data descriptors.

# Regular functions are non-data descriptors
# That's why we can shadow a method on an instance:
class Foo:
    def bar(self):
        return "method"

f = Foo()
f.bar = "instance attr"  # shadows the method
print(f.bar)  # "instance attr" — instance dict wins over non-data

__set_name__ Hook

Added in Python 3.6, __set_name__ is called automatically when the descriptor is assigned to a class attribute. It tells the descriptor what name it was given. We used it in the Validated example above — without it, we’d have to pass the name manually.

When to Use What

  • Simple computed attribute or read-only: @property.
  • Validation on a single attribute: @property with a setter.
  • Same validation logic on multiple attributes: write a custom descriptor (avoids duplicating the property code).

In simple language, @property is the Pythonic way to add getters and setters without ugly method calls. Under the hood, it uses the descriptor protocol — the same mechanism that powers methods, staticmethod, classmethod, and __slots__. When we need reusable validation across many attributes, writing a custom descriptor is the way to go.


22

Abstract Classes & ABC

intermediate ABC abstractmethod abstract-class interface Protocol

An abstract class is a class that can’t be instantiated directly. It exists only to define a contract — “if you’re my subclass, you must implement these methods.” Think of it like a job description — it says what needs to be done, but doesn’t do the work itself.

Why Do We Need Abstract Classes?

Without them, we have no way to enforce that subclasses implement certain methods. We’d only find out at runtime when calling a missing method.

class Shape:
    def area(self):
        pass  # subclasses "should" override this, but nothing forces them

class Circle(Shape):
    pass  # oops, forgot to implement area()

c = Circle()
c.area()  # returns None — no error, just a silent bug

Abstract classes fix this by raising an error at instantiation time, not when the method is called.

abc.ABC and @abstractmethod

We use the abc module (Abstract Base Classes) to create abstract classes.

from abc import ABC, abstractmethod

class Shape(ABC):  # inherit from ABC
    @abstractmethod
    def area(self):
        """Subclasses must implement this."""
        pass

    @abstractmethod
    def perimeter(self):
        pass

# Shape()  # TypeError: Can't instantiate abstract class Shape

Now any subclass must implement both area() and perimeter(), or it’s also abstract and can’t be instantiated.

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14159 * self.radius ** 2

    def perimeter(self):
        return 2 * 3.14159 * self.radius

c = Circle(5)  # works — all abstract methods are implemented
print(c.area())  # 78.53975

If we forget one:

class BadCircle(Shape):
    def area(self):
        return 0
    # forgot perimeter!

# BadCircle()  # TypeError: Can't instantiate abstract class BadCircle
#              # with abstract method perimeter

The error message tells us exactly which methods we missed. Very helpful.

Abstract Properties

We can combine @abstractmethod with @property to require subclasses to implement properties.

from abc import ABC, abstractmethod

class Vehicle(ABC):
    @property
    @abstractmethod
    def fuel_type(self):
        pass

class Car(Vehicle):
    @property
    def fuel_type(self):
        return "Gasoline"

print(Car().fuel_type)  # Gasoline

The order matters: @property goes above @abstractmethod.

Concrete Methods in Abstract Classes

Abstract classes can have regular (concrete) methods too. These provide shared behavior that subclasses inherit for free.

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

    def describe(self):  # concrete — all subclasses get this
        return f"I'm a shape with area {self.area():.2f}"

class Square(Shape):
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side ** 2

print(Square(4).describe())  # I'm a shape with area 16.00

ABCMeta — The Metaclass Behind ABC

ABC is just a convenience class that uses ABCMeta as its metaclass. These two are equivalent:

# Style 1: inherit from ABC
class Shape(ABC):
    pass

# Style 2: use ABCMeta directly
class Shape(metaclass=ABCMeta):
    pass

We almost always use ABC unless we need to combine with another metaclass.

Virtual Subclasses with register()

Sometimes we want to say “this class satisfies the interface” without actual inheritance. register() creates a virtual subclass.

from abc import ABC, abstractmethod

class Drawable(ABC):
    @abstractmethod
    def draw(self):
        pass

class ThirdPartyWidget:  # we can't modify this class
    def draw(self):
        print("Drawing widget")

Drawable.register(ThirdPartyWidget)

print(isinstance(ThirdPartyWidget(), Drawable))  # True
print(issubclass(ThirdPartyWidget, Drawable))     # True

But be careful — register() doesn’t actually check that ThirdPartyWidget implements draw(). It’s a declaration of intent, not enforcement.

ABC vs Duck Typing vs Protocol

Python gives us three ways to define “interfaces”:

ABC (nominal): “You must inherit from me.” Strict, explicit, enforced at instantiation. Good for frameworks where we need guarantees.

Duck typing: “If it has the right methods, it works.” No formal contract. The simplest approach, and the most Pythonic for day-to-day code.

typing.Protocol (structural): “If it has the right methods, the type checker is happy.” Combines duck typing’s flexibility with static analysis. No inheritance needed.

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> None: ...  # just the signature

class Circle:
    def draw(self) -> None:  # no inheritance needed
        print("Drawing circle")

def render(obj: Drawable) -> None:  # type checker validates this
    obj.draw()

render(Circle())  # works — Circle matches the protocol structurally

Protocols are covered in detail in the Protocols & Structural Subtyping topic.

When to Use ABC

  • When we’re building a framework and need to guarantee subclasses implement certain methods.
  • When we want early failure — errors at instantiation, not deep in runtime.
  • When using isinstance() checks against a shared base class.

For most application code, duck typing or Protocol is simpler and more flexible. ABCs shine in library and framework design.

In simple language, abstract classes are contracts that say “implement these methods or you can’t be instantiated.” Python’s abc.ABC and @abstractmethod enforce this contract at creation time. For lighter-weight contracts, duck typing and typing.Protocol are often better fits.


23

Dataclasses & NamedTuple

intermediate dataclass NamedTuple frozen field __post_init__

Most classes we write just hold data. A User with a name and email. A Point with x and y. But every time, we end up writing the same boring __init__, __repr__, and __eq__ methods. Dataclasses fix this by generating all that boilerplate for us.

The Boilerplate Problem

Here’s a plain class that holds data:

class User:
    def __init__(self, name, email, age):
        self.name = name
        self.email = email
        self.age = age

    def __repr__(self):
        return f"User(name={self.name!r}, email={self.email!r}, age={self.age})"

    def __eq__(self, other):
        return isinstance(other, User) and (self.name, self.email, self.age) == (other.name, other.email, other.age)

That’s 12 lines just to store three values. And we’d need even more for __hash__, ordering, etc. With @dataclass, we get all of that in 5 lines.

@dataclass Decorator

from dataclasses import dataclass

@dataclass
class User:
    name: str
    email: str
    age: int

That’s it. Python auto-generates __init__, __repr__, and __eq__ for us. We just declare the fields with type annotations.

u1 = User("Manish", "manish@example.com", 25)
u2 = User("Manish", "manish@example.com", 25)
print(u1)        # User(name='Manish', email='manish@example.com', age=25)
print(u1 == u2)  # True — compares by value, not identity

Default Values and field()

We can set defaults just like function arguments. But mutable defaults (lists, dicts) need field(default_factory=...) to avoid the shared-mutable-default trap.

from dataclasses import dataclass, field

@dataclass
class Team:
    name: str
    members: list[str] = field(default_factory=list)  # each instance gets its own list
    max_size: int = 10  # simple default is fine

If we tried members: list = [], every Team instance would share the same list. default_factory creates a fresh one each time.

__post_init__ for Computed Fields

Sometimes we need a field that’s derived from other fields. __post_init__ runs right after the auto-generated __init__.

@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)  # not in __init__, computed instead

    def __post_init__(self):
        self.area = self.width * self.height

r = Rectangle(4, 5)
print(r.area)  # 20.0

The init=False tells the dataclass “don’t accept this in the constructor — I’ll set it myself.”

frozen=True for Immutability

Adding frozen=True makes the dataclass immutable — we can’t change fields after creation. This also makes instances hashable (usable as dict keys or in sets).

@dataclass(frozen=True)
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
# p.x = 5.0  # FrozenInstanceError — can't modify
print({p: "origin"})  # works as dict key because it's hashable

slots=True and kw_only (Python 3.10+)

slots=True generates __slots__ instead of using __dict__, saving memory. kw_only=True forces all fields to be keyword-only arguments.

@dataclass(slots=True, kw_only=True)
class Config:
    host: str
    port: int = 8080
    debug: bool = False

# Config("localhost")           # TypeError — must use keywords
c = Config(host="localhost")    # works
# c.__dict__                    # AttributeError — uses slots, no __dict__

Typed NamedTuple

NamedTuple is another way to create simple data-holding classes. The key difference: named tuples are tuples. They’re immutable, ordered, and support indexing.

from typing import NamedTuple

class Point(NamedTuple):
    x: float
    y: float

p = Point(1.0, 2.0)
print(p.x)      # 1.0 — access by name
print(p[0])     # 1.0 — access by index (it's a tuple!)
# p.x = 5.0     # AttributeError — tuples are immutable
x, y = p        # unpacking works

@dataclass vs NamedTuple vs Plain Class

Three Approaches Compared
@dataclass
Mutable by default • Has __dict__ (or slots) • Supports defaults, field(), __post_init__ • Can be frozen • Best for most data classes
NamedTuple
Always immutable • It IS a tuple • Supports indexing and unpacking • Hashable by default • Best for simple records and interop with tuple APIs
Plain Class
Full control • Maximum boilerplate • Custom __init__ logic • Best when we need complex behavior beyond just holding data
Rule of thumb: start with @dataclass. Use NamedTuple for immutable records. Use plain class only when we need full customization.

Quick Decision Guide

  • Need mutable data with nice defaults? @dataclass
  • Need an immutable, hashable record that works like a tuple? NamedTuple
  • Need complex initialization logic, custom __new__, or the class is more behavior than data? Plain class

In simple language, dataclasses and named tuples eliminate the boilerplate of writing __init__, __repr__, and __eq__ by hand. @dataclass is the go-to for most data-holding classes, NamedTuple is great when we want immutable tuple-like records, and plain classes are for when we need full control.


24

__slots__ & Memory Optimization

intermediate __slots__ memory optimization __dict__ attribute-access

By default, every Python object stores its attributes in a dictionary (__dict__). That dictionary is flexible — we can add any attribute at any time — but it costs memory. __slots__ replaces that dictionary with a fixed set of attribute slots, saving memory and making attribute access faster.

How __dict__ Works (The Default)

Normally, each instance carries its own __dict__:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(p.__dict__)  # {'x': 1, 'y': 2}
p.z = 3           # we can add attributes on the fly
print(p.__dict__)  # {'x': 1, 'y': 2, 'z': 3}

That flexibility is great, but each __dict__ is a hash table. For a class with just two attributes, we’re paying the overhead of an entire dictionary per instance.

Enter __slots__

__slots__ tells Python “these are the ONLY attributes this class will ever have.” Python then uses a more compact internal structure instead of a dictionary.

class Point:
    __slots__ = ('x', 'y')  # fixed attribute set

    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(p.x)     # 1 — works normally
# p.z = 3      # AttributeError: 'Point' object has no attribute 'z'
# p.__dict__   # AttributeError: 'Point' object has no attribute '__dict__'

We lose the ability to add random attributes, but we gain memory savings and speed.

Memory Savings — Concrete Numbers

The savings are real and measurable. Let’s compare creating a million instances:

import sys

class RegularPoint:
    def __init__(self, x, y):
        self.x = x
        self.y = y

class SlottedPoint:
    __slots__ = ('x', 'y')
    def __init__(self, x, y):
        self.x = x
        self.y = y

r = RegularPoint(1, 2)
s = SlottedPoint(1, 2)
print(sys.getsizeof(r) + sys.getsizeof(r.__dict__))  # ~152 bytes
print(sys.getsizeof(s))                                # ~48 bytes

That’s roughly 3x less memory per instance. With a million objects, that’s the difference between ~150 MB and ~48 MB. The savings come from eliminating the per-instance hash table.

Faster Attribute Access

Because Python knows exactly where each slot is in memory (it’s a fixed offset, not a hash lookup), reading and writing slotted attributes is faster — roughly 10-20% faster in microbenchmarks.

This matters when we’re doing millions of attribute accesses in tight loops.

__slots__ with Inheritance

This is where it gets tricky. If a parent class has __slots__ and a child class doesn’t define __slots__, the child gets __dict__ back.

class Base:
    __slots__ = ('x',)

class Child(Base):  # no __slots__ defined
    pass

c = Child()
c.x = 1   # uses slot from Base
c.y = 2   # works — Child has __dict__ again

For slots to work through the whole chain, every class in the hierarchy needs __slots__.

class Base:
    __slots__ = ('x',)

class Child(Base):
    __slots__ = ('y',)  # only NEW attributes

c = Child()
c.x = 1   # slot from Base
c.y = 2   # slot from Child
# c.z = 3  # AttributeError — no __dict__

Important: don’t repeat parent slots in the child. Only list new attributes in the child’s __slots__.

Combining __slots__ and __dict__

If we want slots for common attributes but still want the flexibility to add extras, we can include '__dict__' in __slots__:

class Flexible:
    __slots__ = ('x', 'y', '__dict__')

    def __init__(self, x, y):
        self.x = x  # stored in slot (fast, compact)
        self.y = y  # stored in slot

f = Flexible(1, 2)
f.z = 3  # stored in __dict__ (flexible)

We get slot-speed for the common attributes and dict-flexibility for extras. But we still pay the __dict__ overhead when we use it.

__slots__ in Dataclasses

Since Python 3.10, dataclasses have a slots=True parameter that handles everything for us:

from dataclasses import dataclass

@dataclass(slots=True)
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
# p.z = 3  # AttributeError — slots in effect

This is the easiest way to get slotted classes. No manual __slots__ definition needed.

When to Use __slots__

Use it when:

  • We’re creating many instances of the same class (thousands or millions)
  • The class has a fixed set of attributes that won’t change
  • Memory or attribute access speed matters (data processing, game objects, ORM rows)

Avoid it when:

  • We need to add attributes dynamically (plugins, monkey-patching)
  • The class has very few instances (savings don’t matter)
  • We’re using multiple inheritance with non-slotted classes (gets messy)

Quick Gotchas

  • No __dict__ means no vars(obj) — we can’t introspect attributes the usual way.
  • No __weakref__ by default — add it to __slots__ if we need weak references.
  • Can’t use __slots__ with variable-length built-in types (like inheriting from str or list).

In simple language, __slots__ trades flexibility for efficiency. Instead of a per-instance dictionary, Python stores attributes in fixed-size slots — using less memory and accessing them faster. We should reach for it when creating lots of instances with a known set of attributes, and @dataclass(slots=True) makes it painless.


25

Composition vs Inheritance

intermediate composition inheritance has-a is-a delegation mixins

“Favor composition over inheritance” is one of the most repeated pieces of advice in OOP. But what does it actually mean, and when should we ignore it? Let’s break it down.

”Is-a” vs “Has-a”

These two phrases capture the fundamental difference:

  • Inheritance (is-a): A Dog IS an Animal. The child class IS a type of the parent.
  • Composition (has-a): A Car HAS an Engine. The class CONTAINS another object as a field.
# Inheritance: Dog IS an Animal
class Animal:
    def breathe(self):
        return "breathing"

class Dog(Animal):  # Dog is-a Animal
    def bark(self):
        return "Woof!"
# Composition: Car HAS an Engine
class Engine:
    def start(self):
        return "vroom"

class Car:
    def __init__(self):
        self.engine = Engine()  # Car has-a Engine

    def start(self):
        return self.engine.start()

The Problem with Deep Inheritance

Inheritance feels natural at first, but deep hierarchies create real problems.

Problem 1: Fragile Base Class — changing the parent class can break all children in unexpected ways.

Problem 2: Tight Coupling — child classes are bound to the parent’s implementation, not just its interface.

Problem 3: The Gorilla-Banana Problem — “You wanted a banana but got a gorilla holding the banana and the entire jungle.” We inherit everything, even what we don’t need.

# A deep hierarchy that's hard to reason about
class Vehicle:
    def move(self): ...
class MotorVehicle(Vehicle):
    def refuel(self): ...
class Car(MotorVehicle):
    def open_trunk(self): ...
class ElectricCar(Car):
    def refuel(self):  # wait, electric cars don't refuel...
        raise NotImplementedError  # awkward override

The ElectricCar problem shows how inheritance can force us into awkward situations. It inherited refuel() from MotorVehicle, but electric cars don’t refuel — they recharge.

Composition Fixes This

With composition, we pick and choose capabilities instead of inheriting a rigid hierarchy.

class GasEngine:
    def power(self):
        return "burning fuel"

class ElectricMotor:
    def power(self):
        return "using battery"

class Car:
    def __init__(self, powertrain):
        self.powertrain = powertrain  # inject the dependency

    def drive(self):
        return f"Driving by {self.powertrain.power()}"

gas_car = Car(GasEngine())
ev = Car(ElectricMotor())
print(ev.drive())  # Driving by using battery

No awkward inheritance. No overriding methods that don’t make sense. We just plug in what we need.

The Delegation Pattern

Delegation is the core mechanism of composition. Instead of inheriting behavior, we forward method calls to a contained object.

class Logger:
    def log(self, msg):
        print(f"[LOG] {msg}")

class UserService:
    def __init__(self):
        self._logger = Logger()  # delegate logging

    def create_user(self, name):
        self._logger.log(f"Creating user: {name}")
        return {"name": name}

The UserService doesn’t inherit from Logger — that would be weird (a user service IS NOT a logger). Instead, it holds a logger and delegates to it.

Mixins: A Middle Ground

Mixins are small, focused classes designed to be mixed into other classes via multiple inheritance. They add a single capability without being a full parent class.

class JsonMixin:
    def to_json(self):
        import json
        return json.dumps(self.__dict__)

class LogMixin:
    def log(self, msg):
        print(f"[{self.__class__.__name__}] {msg}")

class User(JsonMixin, LogMixin):
    def __init__(self, name, email):
        self.name = name
        self.email = email

u = User("Manish", "manish@example.com")
print(u.to_json())  # {"name": "Manish", "email": "manish@example.com"}
u.log("Created")    # [User] Created

Mixins work well when the behavior is orthogonal (unrelated to the class’s main purpose) and doesn’t carry state.

Refactoring: Inheritance to Composition

Let’s take a real example. Here’s an inheritance-based notification system:

# Before: Inheritance — inflexible
class Notifier:
    def send(self, msg):
        raise NotImplementedError

class EmailNotifier(Notifier):
    def send(self, msg):
        print(f"Email: {msg}")

class SlackNotifier(Notifier):
    def send(self, msg):
        print(f"Slack: {msg}")

# What if we need BOTH email AND Slack? Multiple inheritance? Yikes.

Now let’s refactor to composition:

# After: Composition — flexible
class EmailSender:
    def send(self, msg):
        print(f"Email: {msg}")

class SlackSender:
    def send(self, msg):
        print(f"Slack: {msg}")

class Notifier:
    def __init__(self, channels):
        self.channels = channels  # list of senders

    def notify(self, msg):
        for ch in self.channels:
            ch.send(msg)

notifier = Notifier([EmailSender(), SlackSender()])
notifier.notify("Server is down!")  # sends via BOTH channels

With composition, adding a new channel (SMS, webhook, whatever) is just creating a new class with a send() method. No inheritance changes needed.

Inheritance vs Composition
Inheritance Tree
Notifier
EmailNotifier
SlackNotifier
rigid • one channel per class
Composition
Notifier
↓ has
EmailSender
SlackSender
+ any sender
flexible • mix and match

When Inheritance IS the Right Call

Inheritance isn’t evil. It’s the right choice when:

  • There’s a true “is-a” relationship (a Cat really is an Animal)
  • We want to reuse a large chunk of behavior and the parent is stable
  • We’re building a framework that expects subclassing (like Django views, ABC-based contracts)
  • We need isinstance() checks against a common base class

The rule isn’t “never use inheritance.” It’s “don’t reach for inheritance as the default. Consider composition first.”

In simple language, inheritance means “I am a type of that thing,” while composition means “I have that thing inside me.” Composition is more flexible because we can mix and match behaviors at runtime without being locked into a rigid class tree. Use inheritance for true type relationships; use composition for everything else.


26

Metaclasses

advanced metaclass type __new__ __init_subclass__ class-creation

Here’s the mind-bending part of Python: everything is an object, including classes themselves. And if classes are objects, something must create them. That “something” is a metaclass. In simple language, a metaclass is a class whose instances are classes.

Classes Are Objects

When we write class Dog: pass, Python creates an object called Dog. We can assign it to variables, pass it to functions, inspect it — just like any other object.

class Dog:
    pass

print(type(Dog))    # <class 'type'> — Dog is an instance of type
print(type(42))     # <class 'int'> — 42 is an instance of int
print(type(int))    # <class 'type'> — even int is an instance of type

type is the default metaclass. It’s the thing that creates all classes. So: type creates classes, and classes create instances.

The Meta Chain
type (metaclass)
creates classes
↓ creates
Dog (class)
creates instances
↓ creates
buddy (instance)
an actual dog object
type(buddy) = Dogtype(Dog) = typetype(type) = type

type() as a Class Factory

type isn’t just for checking types. We can call it with three arguments to create a class dynamically:

# type(name, bases, dict)
Dog = type('Dog', (), {'speak': lambda self: 'Woof!'})

d = Dog()
print(d.speak())   # Woof!
print(type(d))     # <class '__main__.Dog'>

This is exactly what Python does behind the scenes when it encounters a class statement. The class keyword is just syntactic sugar for calling the metaclass.

__new__ vs __init__ in Regular Classes

Before we get to metaclasses, let’s clarify these two in normal classes:

  • __new__creates the object (allocates memory, returns the new instance)
  • __init__initializes the object (sets up attributes on the already-created instance)
class Foo:
    def __new__(cls, *args):
        print("Creating instance")
        instance = super().__new__(cls)  # actually create the object
        return instance

    def __init__(self, x):
        print("Initializing instance")
        self.x = x

f = Foo(10)  # Creating instance → Initializing instance

We rarely override __new__ in regular classes. But in metaclasses, it’s essential.

Writing a Custom Metaclass

A metaclass is a class that inherits from type. We override __new__ or __init__ to customize how classes are created.

class UpperAttrMeta(type):
    def __new__(mcs, name, bases, namespace):
        # Uppercase all non-dunder attributes
        upper_attrs = {}
        for key, val in namespace.items():
            if key.startswith('__'):
                upper_attrs[key] = val
            else:
                upper_attrs[key.upper()] = val
        return super().__new__(mcs, name, bases, upper_attrs)

class MyClass(metaclass=UpperAttrMeta):
    greeting = "hello"

print(hasattr(MyClass, 'greeting'))   # False
print(hasattr(MyClass, 'GREETING'))   # True
print(MyClass.GREETING)               # hello

Notice: __new__ in a metaclass receives the class namespace (all the attributes and methods defined in the class body) and can modify them before the class is actually created.

__new__ and __init__ in Metaclasses

In a metaclass, both methods work on classes (not instances):

  • __new__(mcs, name, bases, namespace) — called before the class object exists. We can modify the namespace or even return a different class.
  • __init__(cls, name, bases, namespace) — called after the class object is created. Good for registration or validation.
class RegistryMeta(type):
    registry = {}

    def __init__(cls, name, bases, namespace):
        super().__init__(name, bases, namespace)
        if name != 'Base':  # don't register the base class itself
            RegistryMeta.registry[name] = cls

class Base(metaclass=RegistryMeta):
    pass

class UserModel(Base):
    pass

class ProductModel(Base):
    pass

print(RegistryMeta.registry)  # {'UserModel': <class ...>, 'ProductModel': <class ...>}

This automatic registration pattern is how many ORMs (like Django’s models) work under the hood.

__init_subclass__: The Simpler Alternative

Python 3.6 added __init_subclass__ as a way to hook into subclass creation without writing a metaclass. It covers most use cases.

class Base:
    _registry = {}

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        Base._registry[cls.__name__] = cls

class UserModel(Base):
    pass

class ProductModel(Base):
    pass

print(Base._registry)  # {'UserModel': <class ...>, 'ProductModel': <class ...>}

Same result, zero metaclass complexity. For most registration and validation needs, __init_subclass__ is the way to go.

Class Decorators vs Metaclasses

Class decorators can also modify classes after creation. They’re simpler than metaclasses and cover many use cases.

def add_repr(cls):
    def __repr__(self):
        attrs = ', '.join(f'{k}={v!r}' for k, v in self.__dict__.items())
        return f'{cls.__name__}({attrs})'
    cls.__repr__ = __repr__
    return cls

@add_repr
class User:
    def __init__(self, name):
        self.name = name

print(User("Manish"))  # User(name='Manish')

When to use which:

  • Class decorator — simple modifications to one class at a time
  • __init_subclass__ — when we need to hook into subclass creation
  • Metaclass — when we need to control class creation itself (modifying namespace, enforcing constraints on ALL subclasses, or interacting with __new__)

Real Use Cases

Metaclasses aren’t something we write every day. But they power some of the most-used libraries:

  • Django ORMModelBase metaclass collects field definitions and builds database mappings
  • SQLAlchemy — metaclass handles declarative model registration
  • ABCMeta — Python’s own abc.ABC uses a metaclass to track abstract methods
  • EnumEnumMeta metaclass enforces unique values and prevents instantiation

In simple language, metaclasses are classes that create other classes. type is the default metaclass, and we can write custom ones to control how classes are built. But most of the time, __init_subclass__ or a class decorator gets the job done without the complexity.


27

Protocols & Structural Subtyping

advanced Protocol structural-subtyping typing runtime_checkable interface

Python has always had duck typing — “if it walks like a duck and quacks like a duck, it’s a duck.” But duck typing happens at runtime. What if we want the type checker to verify our ducks before the code runs? That’s what typing.Protocol gives us: static duck typing.

Nominal vs Structural Subtyping

There are two ways a language can decide if a type “fits”:

Nominal subtyping: “You ARE this type because you explicitly said so” (by inheriting). Java interfaces, Python ABCs — the class must declare its lineage.

Structural subtyping: “You ARE this type because you have the right shape” (the right methods/attributes). No inheritance needed. If it has a draw() method, it’s drawable.

In simple language, nominal is about who you are, structural is about what you can do.

Defining a Protocol

A Protocol is just a class that declares what methods or attributes something should have. Classes don’t need to inherit from it — they just need to match the shape.

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> str: ...  # just the signature, no body needed

class Circle:
    def draw(self) -> str:  # matches Drawable — no inheritance!
        return "Drawing circle"

class Square:
    def draw(self) -> str:
        return "Drawing square"

def render(shape: Drawable) -> None:  # type checker validates this
    print(shape.draw())

render(Circle())  # works — Circle matches the Protocol
render(Square())  # works — Square matches too

Neither Circle nor Square inherits from Drawable. They just happen to have a draw() method with the right signature. The type checker (mypy, pyright) validates this statically.

Protocols with Attributes

Protocols can require attributes, not just methods:

from typing import Protocol

class Named(Protocol):
    name: str  # any class with a 'name: str' attribute matches

class User:
    def __init__(self, name: str):
        self.name = name

class Bot:
    name: str = "AutoBot"

def greet(entity: Named) -> str:
    return f"Hello, {entity.name}!"

print(greet(User("Manish")))  # Hello, Manish!
print(greet(Bot()))           # Hello, AutoBot!

@runtime_checkable

By default, Protocols only work at type-checking time. If we want to use isinstance() checks at runtime, we add @runtime_checkable:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None: ...

class FileHandler:
    def close(self) -> None:
        print("File closed")

f = FileHandler()
print(isinstance(f, Closeable))  # True — runtime check works!

A heads-up: runtime isinstance() checks with Protocols only verify that the methods exist. They don’t check argument types or return types. For full validation, we rely on the static type checker.

Protocol vs ABC

Both define “interfaces,” but they work very differently:

ABC (nominal): Classes must explicitly inherit from the ABC. Enforced at instantiation time. Can have concrete methods and state.

Protocol (structural): No inheritance needed. Enforced by the type checker, not at runtime (unless @runtime_checkable). Pure interface — no implementation.

from abc import ABC, abstractmethod
from typing import Protocol

# ABC approach — must inherit
class DrawableABC(ABC):
    @abstractmethod
    def draw(self) -> str: ...

class Circle(DrawableABC):  # MUST inherit
    def draw(self) -> str:
        return "circle"

# Protocol approach — no inheritance
class DrawableProto(Protocol):
    def draw(self) -> str: ...

class Square:  # no inheritance needed
    def draw(self) -> str:
        return "square"

When to use which:

  • ABC — when we own the hierarchy and want runtime enforcement (frameworks, plugin systems)
  • Protocol — when we want flexibility, especially with third-party code we can’t modify

Built-in Protocols

Python’s typing module comes with several Protocols we use all the time (even if we didn’t know they were Protocols):

from typing import Sized, Iterable, SupportsFloat, SupportsInt

# Any class with __len__ matches Sized
class Bag:
    def __len__(self) -> int:
        return 5

print(isinstance(Bag(), Sized))  # True

# SupportsFloat — anything with __float__
class Temp:
    def __float__(self) -> float:
        return 98.6

print(float(Temp()))  # 98.6

Other built-in protocols include SupportsAbs, SupportsRound, SupportsBytes, Hashable, and Reversible.

Practical Example: Repository Pattern

Protocols shine for dependency injection. Here’s a repository pattern where the service doesn’t care about the concrete storage implementation:

from typing import Protocol

class UserRepo(Protocol):
    def get(self, user_id: int) -> dict: ...
    def save(self, user: dict) -> None: ...

class PostgresRepo:  # no inheritance from UserRepo
    def get(self, user_id: int) -> dict:
        return {"id": user_id, "name": "from postgres"}

    def save(self, user: dict) -> None:
        print(f"Saved to Postgres: {user}")

class InMemoryRepo:  # great for testing
    def __init__(self):
        self.store = {}

    def get(self, user_id: int) -> dict:
        return self.store.get(user_id, {})

    def save(self, user: dict) -> None:
        self.store[user["id"]] = user
class UserService:
    def __init__(self, repo: UserRepo):  # accepts ANY matching repo
        self.repo = repo

    def get_user(self, user_id: int) -> dict:
        return self.repo.get(user_id)

# Production
service = UserService(PostgresRepo())

# Testing — swap in a fake, no mocking needed
test_service = UserService(InMemoryRepo())

Neither PostgresRepo nor InMemoryRepo knows about UserRepo. They just happen to have the right methods. The type checker validates everything, and we get full flexibility for testing.

Combining Multiple Protocols

We can compose Protocols to build complex interfaces from small pieces:

from typing import Protocol

class Readable(Protocol):
    def read(self) -> str: ...

class Writable(Protocol):
    def write(self, data: str) -> None: ...

class ReadWrite(Readable, Writable, Protocol):
    ...  # combines both

def process(stream: ReadWrite) -> None:
    data = stream.read()
    stream.write(data.upper())

This is Python’s version of interface segregation — small, focused protocols instead of one big interface.

In simple language, typing.Protocol is Python’s way of formalizing duck typing. Instead of forcing classes to inherit from an interface, we just describe the shape we expect and let the type checker verify it. It’s perfect for flexible code that works with any class that has the right methods — no inheritance required.


28

SOLID Principles in Python

advanced SOLID SRP OCP LSP ISP DIP design-principles

SOLID is a set of five design principles that help us write code that’s easier to maintain, extend, and test. They were coined for statically typed OOP languages, but they apply just as well to Python — we just implement them more idiomatically using duck typing, protocols, and first-class functions.

S — Single Responsibility Principle

A class should have only one reason to change. Each class does one thing, and does it well.

Bad — one class doing too many things:

class UserManager:
    def create_user(self, name, email):
        # validates, saves to DB, AND sends email — three responsibilities
        if "@" not in email:
            raise ValueError("Invalid email")
        self._save_to_db(name, email)
        self._send_welcome_email(email)

Good — separate concerns into focused classes:

class UserValidator:
    def validate(self, email: str) -> bool:
        return "@" in email

class UserRepository:
    def save(self, name: str, email: str) -> None:
        print(f"Saved {name} to DB")  # database logic only

class EmailService:
    def send_welcome(self, email: str) -> None:
        print(f"Welcome email sent to {email}")  # email logic only

Now if the email provider changes, we only touch EmailService. If the database changes, we only touch UserRepository. Each class has one reason to change.

O — Open/Closed Principle

Open for extension, closed for modification. We should be able to add new behavior without changing existing code.

Bad — modifying the class every time we add a new format:

class ReportExporter:
    def export(self, data, format):
        if format == "json":
            return json.dumps(data)
        elif format == "csv":
            return ",".join(data)
        # every new format = another elif here

Good — use a Protocol (or ABC) so new formats are new classes:

from typing import Protocol

class Exporter(Protocol):
    def export(self, data: list) -> str: ...

class JsonExporter:
    def export(self, data: list) -> str:
        import json
        return json.dumps(data)

class CsvExporter:
    def export(self, data: list) -> str:
        return ",".join(str(item) for item in data)

def generate_report(data: list, exporter: Exporter) -> str:
    return exporter.export(data)  # works with ANY exporter

Adding XML export? Just create XmlExporter. We never touch existing code.

L — Liskov Substitution Principle

Subtypes must be substitutable for their base types without breaking anything. If our code works with a Bird, it should work with any subclass of Bird too.

Bad — subclass breaks the parent’s contract:

class Bird:
    def fly(self) -> str:
        return "flying"

class Penguin(Bird):
    def fly(self) -> str:
        raise NotImplementedError("Penguins can't fly!")  # breaks the contract

Any code that calls bird.fly() will blow up if it gets a Penguin. That’s a Liskov violation.

Good — restructure so the contract holds:

from typing import Protocol

class Bird(Protocol):
    def move(self) -> str: ...

class Sparrow:
    def move(self) -> str:
        return "flying through the air"

class Penguin:
    def move(self) -> str:
        return "swimming through the water"

def travel(bird: Bird) -> None:
    print(bird.move())  # works for ALL birds — no surprises

travel(Sparrow())  # flying through the air
travel(Penguin())  # swimming through the water

The key insight: if a subclass can’t fully honor the parent’s behavior, the hierarchy is wrong. Fix the abstraction, not the subclass.

I — Interface Segregation Principle

Don’t force classes to implement methods they don’t use. Keep interfaces small and focused.

Bad — one fat interface:

from abc import ABC, abstractmethod

class Worker(ABC):
    @abstractmethod
    def code(self) -> None: ...

    @abstractmethod
    def test(self) -> None: ...

    @abstractmethod
    def design(self) -> None: ...

class BackendDev(Worker):
    def code(self): print("coding")
    def test(self): print("testing")
    def design(self): pass  # forced to implement something irrelevant

Good — small, focused protocols:

from typing import Protocol

class Coder(Protocol):
    def code(self) -> None: ...

class Tester(Protocol):
    def test(self) -> None: ...

class Designer(Protocol):
    def design(self) -> None: ...

class BackendDev:
    def code(self): print("coding")
    def test(self): print("testing")
    # no design() — and that's perfectly fine

class UiDesigner:
    def design(self): print("designing")
    # no code() or test() — also fine

def run_tests(tester: Tester) -> None:
    tester.test()  # only needs what it actually uses

Each Protocol asks for exactly what it needs, nothing more. Classes implement only what makes sense for them.

D — Dependency Inversion Principle

Depend on abstractions, not concrete implementations. High-level modules shouldn’t depend on low-level modules. Both should depend on abstractions (protocols).

Bad — high-level code depends on a concrete class:

class MySqlDatabase:
    def query(self, sql: str) -> list:
        return [{"id": 1}]  # MySQL-specific

class UserService:
    def __init__(self):
        self.db = MySqlDatabase()  # hardcoded dependency — can't swap

    def get_users(self) -> list:
        return self.db.query("SELECT * FROM users")

Good — depend on a Protocol and inject the dependency:

from typing import Protocol

class Database(Protocol):
    def query(self, sql: str) -> list: ...

class MySqlDatabase:
    def query(self, sql: str) -> list:
        return [{"id": 1}]

class UserService:
    def __init__(self, db: Database):  # accepts any Database
        self.db = db

    def get_users(self) -> list:
        return self.db.query("SELECT * FROM users")

# Production
service = UserService(MySqlDatabase())

# Testing — swap in a fake
class FakeDb:
    def query(self, sql: str) -> list:
        return [{"id": 99, "name": "test"}]

test_service = UserService(FakeDb())

Constructor injection + Protocol = easy to test, easy to swap implementations, and zero coupling to concrete classes.

SOLID in Python — The Pragmatic View

Python is more flexible than Java or C#, so we apply SOLID with some nuance:

  • SRP — use modules and functions, not just classes. A module can be a “unit of responsibility.”
  • OCP — first-class functions and duck typing often remove the need for elaborate class hierarchies.
  • LSP — duck typing makes this about behavioral contracts, not just type hierarchies.
  • ISP — Protocols are perfect for this. Small protocols > fat ABCs.
  • DIP — constructor injection with Protocol types. Python’s dynamic nature makes DI frameworks largely unnecessary.

In simple language, SOLID gives us five rules for writing maintainable OOP code. In Python, we implement them idiomatically: small classes and modules for SRP, Protocols for OCP/ISP/DIP, and well-designed abstractions for LSP. The goal isn’t to follow the letters religiously — it’s to write code that’s easy to change, test, and extend.


29

OOP Design Patterns in Python

advanced design-patterns singleton factory observer strategy decorator iterator

Design patterns are proven solutions to common problems. But Python’s flexibility means we often implement them differently than Java or C++. First-class functions, decorators, and duck typing let us skip a lot of the ceremony. Here are the patterns that come up most in Python.

Singleton — One Instance Only

A singleton ensures only one instance of a class exists. In Python, the simplest approach is just a module-level instance. But here’s the __new__ approach for when we need a class:

class Database:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance.connection = "connected"  # init once
        return cls._instance

db1 = Database()
db2 = Database()
print(db1 is db2)  # True — same object

The Pythonic way: just use a module. A module is imported once, so module-level variables are natural singletons.

# config.py
settings = {"debug": True, "db_url": "postgres://..."}
# everywhere else: from config import settings

Factory — @classmethod Factories

Factory methods create objects without exposing the creation logic. In Python, @classmethod is the natural fit.

class User:
    def __init__(self, name: str, role: str):
        self.name = name
        self.role = role

    @classmethod
    def admin(cls, name: str) -> "User":
        return cls(name, role="admin")

    @classmethod
    def from_dict(cls, data: dict) -> "User":
        return cls(data["name"], data["role"])

admin = User.admin("Manish")
user = User.from_dict({"name": "Raj", "role": "viewer"})
print(admin.role)  # admin

We get named constructors that clearly communicate intent — much better than passing flags to __init__.

Builder — Fluent Method Chaining

The builder pattern constructs complex objects step by step. In Python, we return self from each method to enable chaining.

class QueryBuilder:
    def __init__(self):
        self._table = ""
        self._conditions = []
        self._limit = None

    def table(self, name: str) -> "QueryBuilder":
        self._table = name
        return self  # return self for chaining

    def where(self, condition: str) -> "QueryBuilder":
        self._conditions.append(condition)
        return self

    def limit(self, n: int) -> "QueryBuilder":
        self._limit = n
        return self

    def build(self) -> str:
        query = f"SELECT * FROM {self._table}"
        if self._conditions:
            query += " WHERE " + " AND ".join(self._conditions)
        if self._limit:
            query += f" LIMIT {self._limit}"
        return query

sql = QueryBuilder().table("users").where("age > 18").where("active = 1").limit(10).build()
print(sql)  # SELECT * FROM users WHERE age > 18 AND active = 1 LIMIT 10

Observer — Event System with Callbacks

The observer pattern lets objects subscribe to events on another object. In Python, we use simple callback lists.

class EventEmitter:
    def __init__(self):
        self._listeners = {}  # event_name -> list of callbacks

    def on(self, event: str, callback):
        self._listeners.setdefault(event, []).append(callback)

    def emit(self, event: str, *args):
        for cb in self._listeners.get(event, []):
            cb(*args)

emitter = EventEmitter()
emitter.on("user_created", lambda name: print(f"Welcome, {name}!"))
emitter.on("user_created", lambda name: print(f"Sending email to {name}"))
emitter.emit("user_created", "Manish")
# Welcome, Manish!
# Sending email to Manish

Because functions are first-class in Python, we don’t need separate Observer and Subject interfaces. A callback is enough.

Strategy — Functions as Strategies

The strategy pattern swaps algorithms at runtime. In Java, this means interfaces and classes. In Python, we just pass functions.

def bubble_sort(data: list) -> list:
    items = data[:]
    for i in range(len(items)):
        for j in range(len(items) - 1 - i):
            if items[j] > items[j + 1]:
                items[j], items[j + 1] = items[j + 1], items[j]
    return items

def builtin_sort(data: list) -> list:
    return sorted(data)

class Sorter:
    def __init__(self, strategy=builtin_sort):  # default strategy
        self.strategy = strategy

    def sort(self, data: list) -> list:
        return self.strategy(data)

s = Sorter(strategy=bubble_sort)  # swap strategy at construction
print(s.sort([3, 1, 2]))  # [1, 2, 3]

The only difference from the classic pattern is that we pass a function instead of an object. Python’s first-class functions make the pattern almost invisible.

Decorator — Function and Class-Based

The decorator pattern wraps an object to add behavior. Python has it baked into the language with @decorator syntax.

Function decorator (the most common form):

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
        return result
    return wrapper

@timer
def slow_function():
    time.sleep(0.1)

slow_function()  # slow_function took 0.1002s

Class-based decorator (when we need state):

class CountCalls:
    def __init__(self, func):
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"Call #{self.count}")
        return self.func(*args, **kwargs)

@CountCalls
def greet(name):
    return f"Hello, {name}!"

greet("Manish")  # Call #1 → Hello, Manish!
greet("Raj")     # Call #2 → Hello, Raj!
print(greet.count)  # 2 — state is preserved

Iterator — __iter__ / __next__ and Generators

The iterator pattern provides a way to traverse a collection without exposing its internal structure. Python’s iterator protocol is built on two dunder methods.

class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self  # the object is its own iterator

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        val = self.current
        self.current -= 1
        return val

for num in Countdown(3):
    print(num)  # 3, 2, 1

The Pythonic way: generators do the same thing with way less code.

def countdown(start):
    while start > 0:
        yield start  # pauses here, resumes on next iteration
        start -= 1

for num in countdown(3):
    print(num)  # 3, 2, 1

Generators are lazy iterators built into the language. For most cases, they replace the need for a full iterator class.

Template Method — ABC with Hook Methods

The template method defines the skeleton of an algorithm in a base class, letting subclasses fill in specific steps. Think of it like a form with blanks to fill in.

from abc import ABC, abstractmethod

class DataPipeline(ABC):
    def run(self):  # template method — defines the steps
        data = self.extract()
        cleaned = self.transform(data)
        self.load(cleaned)

    @abstractmethod
    def extract(self) -> list: ...

    @abstractmethod
    def transform(self, data: list) -> list: ...

    @abstractmethod
    def load(self, data: list) -> None: ...

class CsvPipeline(DataPipeline):
    def extract(self) -> list:
        return ["raw1", "raw2"]  # read from CSV

    def transform(self, data: list) -> list:
        return [d.upper() for d in data]  # clean data

    def load(self, data: list) -> None:
        print(f"Loaded: {data}")  # save to DB

CsvPipeline().run()  # Loaded: ['RAW1', 'RAW2']

The run() method is the template. It calls extract, transform, and load in order. Subclasses provide the concrete implementations, but the overall flow stays the same.

Which Pattern, When?

  • Singleton — configuration, database connections, caches (but prefer modules)
  • Factory — multiple ways to create an object (from_json, from_csv, admin)
  • Builder — constructing complex objects step by step (queries, configs)
  • Observer — event-driven systems, pub/sub, UI updates
  • Strategy — swappable algorithms (sorting, validation, pricing rules)
  • Decorator — adding behavior without modifying the original (logging, timing, auth)
  • Iterator — traversing collections lazily (prefer generators)
  • Template Method — fixed algorithm with customizable steps (ETL, tests, workflows)

In simple language, design patterns are reusable solutions to common problems. Python’s features — first-class functions, decorators, generators, duck typing — let us implement these patterns with far less boilerplate than traditional OOP languages. The pattern is still there; the ceremony isn’t.