Back to Articles
24 min read

JavaScript Object Architecture: Prototypes, Inheritance, and Classes

JavaScript's inheritance model is unique and often misunderstood. This guide peels back the syntactic sugar of ES6 Classes to reveal the prototypal engine underneath. We analyze the transition from constructor functions to `class` keywords, explore the mechanics of the prototype chain, and implement robust OOP patterns using private fields, mixins, and polymorphism.

Prototypes and Inheritance

Prototype Chain

Every JavaScript object has an internal link to another object called its prototype; when accessing a property, JS traverses this chain until the property is found or the chain ends at null.

myObj  →  Parent.prototype  →  Object.prototype  →  null
  │            │                      │
  └── own      └── inherited          └── toString, hasOwnProperty, etc.
      props        props

proto vs prototype

__proto__ is the actual link on an object instance pointing to its prototype, while prototype is a property on constructor functions that becomes the __proto__ of instances created with new.

function Dog(name) { this.name = name; } Dog.prototype.bark = () => "Woof!"; const rex = new Dog("Rex"); console.log(rex.__proto__ === Dog.prototype); // true console.log(Dog.__proto__ === Function.prototype); // true

Object.getPrototypeOf, Object.setPrototypeOf

These are the standard methods to read and modify an object's prototype; prefer getPrototypeOf over __proto__ for reading, and avoid setPrototypeOf in performance-critical code as it disrupts engine optimizations.

const animal = { eats: true }; const rabbit = { jumps: true }; Object.setPrototypeOf(rabbit, animal); console.log(Object.getPrototypeOf(rabbit) === animal); // true console.log(rabbit.eats); // true (inherited)

Constructor Functions

Constructor functions are regular functions designed to be called with new, conventionally capitalized, that initialize object instances using this to assign properties.

function Person(name, age) { this.name = name; // Instance property this.age = age; } Person.prototype.greet = function() { return `Hi, I'm ${this.name}`; }; const john = new Person("John", 30);

new Keyword

The new keyword creates a fresh object, sets its prototype to the constructor's .prototype, executes the constructor with this bound to the new object, and returns the object (unless constructor explicitly returns an object).

// What 'new' does internally: function myNew(Constructor, ...args) { const obj = {}; // 1. Create object Object.setPrototypeOf(obj, Constructor.prototype); // 2. Link prototype const result = Constructor.apply(obj, args); // 3. Call constructor return result instanceof Object ? result : obj; // 4. Return object }

instanceof Operator

instanceof checks if an object's prototype chain contains the .prototype property of a constructor, returning true or false.

function Car() {} const tesla = new Car(); console.log(tesla instanceof Car); // true console.log(tesla instanceof Object); // true console.log([] instanceof Array); // true console.log([] instanceof Object); // true

Prototypal Inheritance

JavaScript uses prototypal inheritance where objects inherit directly from other objects, unlike classical inheritance; you link objects via the prototype chain rather than copying properties from classes.

┌─────────────┐
│   Animal    │  ← Base prototype
│  eat()      │
└──────┬──────┘
       │ [[Prototype]]
┌──────▼──────┐
│     Dog     │  ← Inherits from Animal
│  bark()     │
└──────┬──────┘
       │ [[Prototype]]
┌──────▼──────┐
│    rex      │  ← Instance
│  name:"Rex" │
└─────────────┘

Object.create for Inheritance

Object.create(proto) creates a new object with the specified prototype, providing a clean way to set up prototypal inheritance without constructor functions.

const animal = { eat() { console.log("eating"); } }; const dog = Object.create(animal); dog.bark = function() { console.log("woof"); }; const rex = Object.create(dog); rex.name = "Rex"; rex.eat(); // "eating" - inherited from animal rex.bark(); // "woof" - inherited from dog

Prototype Methods

Methods defined on a constructor's .prototype are shared across all instances, saving memory compared to defining methods inside the constructor.

function Circle(radius) { this.radius = radius; // Instance property (copied) } Circle.prototype.area = function() { // Shared method (single copy) return Math.PI * this.radius ** 2; }; const c1 = new Circle(5); const c2 = new Circle(10); console.log(c1.area === c2.area); // true (same function reference)

Own Properties vs Inherited Properties

Own properties exist directly on the object itself, while inherited properties come from somewhere in the prototype chain; both are accessible via dot notation but behave differently in enumeration and modification.

function Person(name) { this.name = name; } // own Person.prototype.species = "Human"; // inherited const john = new Person("John"); console.log(john.name); // "John" (own) console.log(john.species); // "Human" (inherited) john.species = "Alien"; // Creates OWN property, doesn't modify prototype console.log(new Person("Jane").species); // Still "Human"

hasOwnProperty

hasOwnProperty() returns true only if the property exists directly on the object, not inherited from the prototype chain; essential for safe property checks.

const obj = { a: 1 }; Object.prototype.b = 2; console.log(obj.hasOwnProperty('a')); // true console.log(obj.hasOwnProperty('b')); // false console.log(obj.hasOwnProperty('toString')); // false // Safer version (if hasOwnProperty could be overwritten) console.log(Object.prototype.hasOwnProperty.call(obj, 'a')); // true

in Operator

The in operator checks if a property exists anywhere in the object or its prototype chain, returning true for both own and inherited properties.

const obj = { own: 1 }; console.log('own' in obj); // true (own property) console.log('toString' in obj); // true (inherited from Object.prototype) console.log('random' in obj); // false // Comparison console.log(obj.hasOwnProperty('toString')); // false console.log('toString' in obj); // true

Property Enumeration

Different methods enumerate different properties: for...in includes inherited enumerable properties, Object.keys() returns only own enumerable keys, and Object.getOwnPropertyNames() includes non-enumerable own properties.

const parent = { inherited: 1 }; const child = Object.create(parent); child.own = 2; Object.defineProperty(child, 'hidden', { value: 3, enumerable: false }); for (let key in child) console.log(key); // own, inherited console.log(Object.keys(child)); // ['own'] console.log(Object.getOwnPropertyNames(child)); // ['own', 'hidden'] // Safe for...in iteration for (let key in child) { if (child.hasOwnProperty(key)) console.log(key); // only 'own' }

Classes (ES6+)

Class Declaration

A class declaration defines a named class using the class keyword, serving as a blueprint for creating objects with shared properties and methods; unlike function declarations, class declarations are not hoisted.

class Person { constructor(name) { this.name = name; } } const john = new Person('John');

Class Expression

A class expression assigns a class (named or anonymous) to a variable, useful for dynamic class creation or passing classes as arguments to functions.

const Animal = class { speak() { return 'Sound'; } }; // Named class expression const Dog = class DogClass { bark() { return 'Woof'; } };

Constructor

The constructor is a special method called automatically when creating a new instance with new, used to initialize object properties and set up initial state.

class User { constructor(name, age) { this.name = name; // Initialize properties this.age = age; } } const user = new User('Alice', 30);

Instance Methods

Instance methods are functions defined inside a class that operate on individual instances, having access to this which refers to the specific object calling the method.

class Calculator { constructor(value) { this.value = value; } add(n) { this.value += n; return this; } // Instance method multiply(n) { this.value *= n; return this; } // Chainable } new Calculator(5).add(3).multiply(2); // value = 16

Static Methods

Static methods belong to the class itself rather than instances, called directly on the class and commonly used for utility functions that don't require instance data.

class MathUtils { static add(a, b) { return a + b; } static PI = 3.14159; } MathUtils.add(5, 3); // 8 (called on class, not instance) // new MathUtils().add(5,3) // ERROR!

Static Properties

Static properties are data values attached to the class constructor itself, shared across all instances and accessed via the class name rather than this.

class Config { static appName = 'MyApp'; static version = '1.0.0'; static getInfo() { return `${this.appName} v${this.version}`; } } console.log(Config.appName); // 'MyApp'

Instance Properties

Instance properties are unique to each object instance, can be declared in constructor or as class fields (ES2022+), representing the state of individual objects.

class Product { category = 'General'; // Class field (default value) constructor(name, price) { this.name = name; // Constructor property this.price = price; } }

Private Fields (#)

Private fields, prefixed with #, are truly encapsulated properties accessible only within the class body, providing real data privacy unlike the older underscore convention.

class BankAccount { #balance = 0; // Private field deposit(amount) { this.#balance += amount; } getBalance() { return this.#balance; } } const acc = new BankAccount(); acc.deposit(100); // acc.#balance; // SyntaxError: Private field

Private Methods

Private methods use the # prefix to create methods only callable from within the class, useful for internal helper functions that shouldn't be part of the public API.

class Validator { #sanitize(str) { return str.trim().toLowerCase(); } // Private validate(input) { const clean = this.#sanitize(input); // Internal use only return clean.length > 0; } }

Getters and Setters in Classes

Getters and setters define computed properties using get and set keywords, allowing controlled access to private data with validation, transformation, or lazy computation.

class Circle { #radius = 0; get radius() { return this.#radius; } set radius(value) { if (value < 0) throw new Error('Invalid radius'); this.#radius = value; } get area() { return Math.PI * this.#radius ** 2; } // Computed } const c = new Circle(); c.radius = 5; // Uses setter console.log(c.area); // Uses getter: 78.54

extends Keyword

The extends keyword creates a child class that inherits all properties and methods from a parent class, establishing a prototype chain for code reuse and specialization.

class Animal { constructor(name) { this.name = name; } speak() { return `${this.name} makes a sound`; } } class Dog extends Animal { bark() { return `${this.name} barks`; } } new Dog('Rex').speak(); // 'Rex makes a sound'

super Keyword

The super keyword calls the parent class constructor or methods, required in child constructor before using this, and used to extend rather than replace parent behavior.

class Animal { constructor(name) { this.name = name; } speak() { return 'Generic sound'; } } class Cat extends Animal { constructor(name, color) { super(name); // MUST call before 'this' this.color = color; } speak() { return super.speak() + ' - Meow!'; } // Extend parent }

Method Overriding

Method overriding replaces a parent class method in a child class with a new implementation, allowing specialized behavior while optionally calling the parent version via super.

class Shape { getArea() { return 0; } } class Rectangle extends Shape { constructor(w, h) { super(); this.w = w; this.h = h; } getArea() { return this.w * this.h; } // Override parent } class Square extends Rectangle { constructor(side) { super(side, side); } // Inherits getArea from Rectangle }

Class Inheritance

Class inheritance creates a hierarchy where child classes inherit and extend parent functionality, forming an "is-a" relationship with shared code traveling up the prototype chain.

┌─────────────────┐
│     Vehicle     │  ← Base class
├─────────────────┤
│ start(), stop() │
└────────┬────────┘
         │ extends
    ┌────┴────┐
    ▼         ▼
┌───────┐ ┌───────┐
│  Car  │ │ Bike  │  ← Child classes
└───────┘ └───────┘
class Vehicle { start() { return 'Starting'; } } class Car extends Vehicle { drive() { return 'Driving'; } }

Abstract Classes (Pattern)

JavaScript lacks native abstract classes, but you can simulate them by throwing errors in methods that must be overridden, enforcing implementation contracts at runtime.

class AbstractShape { constructor() { if (new.target === AbstractShape) { throw new Error('Cannot instantiate abstract class'); } } getArea() { throw new Error('Method getArea() must be implemented'); } } class Circle extends AbstractShape { constructor(r) { super(); this.r = r; } getArea() { return Math.PI * this.r ** 2; } // Must implement }

Mixins

Mixins are a pattern to add functionality from multiple sources to a class, working around JavaScript's single inheritance limitation by copying methods from helper objects.

const Flyable = { fly() { return `${this.name} is flying`; } }; const Swimmable = { swim() { return `${this.name} is swimming`; } }; class Duck { constructor(name) { this.name = name; } } Object.assign(Duck.prototype, Flyable, Swimmable); // Mixin new Duck('Donald').fly(); // 'Donald is flying'