MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Understanding JavaScript Prototypes and Inheritance

Deep dive into JavaScript's prototype chain, constructor functions, and ES6 classes.

JavaScriptPrototypesOOP

By MinhVo

Introduction

JavaScript's approach to object-oriented programming is fundamentally different from classical languages like Java or C++. Instead of using classes as blueprints (at least pre-ES6), JavaScript uses a prototype-based inheritance model where objects inherit directly from other objects. Understanding this mechanism is crucial for writing effective JavaScript, debugging mysterious property lookups, and designing performant object hierarchies.

In this deep dive, we'll explore the prototype chain from first principles, examine how constructor functions and ES6 classes relate to the underlying prototype system, and uncover patterns that senior JavaScript developers use in production codebases. Whether you're debugging a framework issue or designing a library API, prototype mastery is non-negotiable.

JavaScript Prototypes and Inheritance

Understanding Prototypes: Core Concepts

What Is a Prototype?

Every JavaScript object has an internal link to another object called its prototype. When you access a property on an object, JavaScript first checks if the property exists directly on the object (its "own" properties). If it doesn't find it, it follows the prototype link and checks the prototype object. This continues up the chain until the property is found or the chain ends (at null).

const animal = {
  eats: true,
  walk() {
    console.log('Animal walks');
  }
};
 
const rabbit = Object.create(animal);
rabbit.jumps = true;
 
console.log(rabbit.jumps); // true — own property
console.log(rabbit.eats);  // true — inherited from animal
rabbit.walk();              // "Animal walks" — inherited method

The Prototype Chain

The prototype chain is the series of links between objects. When JavaScript can't find a property on an object, it traverses this chain upward.

const grandparent = { legacy: 'wealth' };
const parent = Object.create(grandparent);
parent.name = 'Parent';
 
const child = Object.create(parent);
child.name = 'Child';
 
// Property lookup: child -> parent -> grandparent -> null
console.log(child.legacy);  // 'wealth' (found on grandparent)
console.log(child.name);    // 'Child' (found on child itself)

__proto__ vs Object.getPrototypeOf()

The __proto__ property is a deprecated accessor for the internal [[Prototype]] slot. The modern way to access an object's prototype is Object.getPrototypeOf().

const obj = {};
console.log(Object.getPrototypeOf(obj) === Object.prototype); // true
console.log(obj.__proto__ === Object.prototype); // true (deprecated)

Prototype Chain Visualization

Architecture and Design Patterns

Constructor Functions

Before ES6 classes, constructor functions were the standard way to create object templates. A constructor function is called with new, which creates a new object and sets its prototype to the constructor's prototype property.

function Person(name: string, age: number) {
  this.name = name;
  this.age = age;
}
 
Person.prototype.greet = function() {
  return `Hi, I'm ${this.name}, ${this.age} years old.`;
};
 
Person.prototype.birthday = function() {
  this.age++;
  return this.age;
};
 
const alice = new Person('Alice', 30);
console.log(alice.greet()); // "Hi, I'm Alice, 30 years old."
console.log(alice.birthday()); // 31

What new Does

The new keyword performs four operations:

  1. Creates a new empty object
  2. Links the object's [[Prototype]] to the constructor's prototype property
  3. Calls the constructor with this set to the new object
  4. Returns the object (unless the constructor explicitly returns a different object)
// Manual equivalent of new Person('Alice', 30)
function manualNew(constructor, ...args) {
  const obj = Object.create(constructor.prototype);
  const result = constructor.apply(obj, args);
  return result instanceof Object ? result : obj;
}

Prototypal Inheritance Patterns

Pattern 1: Object.create

The cleanest way to set up prototypal inheritance between objects.

const shape = {
  type: 'shape',
  describe() {
    return `This is a ${this.type}`;
  }
};
 
const circle = Object.create(shape);
circle.type = 'circle';
circle.radius = 5;
circle.area = function() {
  return Math.PI * this.radius ** 2;
};
 
console.log(circle.describe()); // "This is a circle"
console.log(circle.area());     // 78.54

Pattern 2: Constructor Inheritance

To set up inheritance between constructor functions, you need to link prototypes and call the parent constructor.

function Animal(name: string) {
  this.name = name;
  this.speed = 0;
}
 
Animal.prototype.move = function(speed: number) {
  this.speed = speed;
  return `${this.name} runs at speed ${speed}`;
};
 
function Dog(name: string, breed: string) {
  Animal.call(this, name); // Call parent constructor
  this.breed = breed;
}
 
// Set up prototype chain
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
 
Dog.prototype.bark = function() {
  return `${this.name} says Woof!`;
};
 
const rex = new Dog('Rex', 'German Shepherd');
console.log(rex.move(10)); // "Rex runs at speed 10"
console.log(rex.bark());   // "Rex says Woof!"

Step-by-Step Implementation

Property Descriptors and Prototype Behavior

Understanding property descriptors is essential for controlling how prototype properties behave.

const parent = {};
 
Object.defineProperty(parent, 'inherited', {
  value: 42,
  writable: false,
  enumerable: true,
  configurable: false
});
 
const child = Object.create(parent);
console.log(child.inherited); // 42
 
// This creates a new own property, doesn't modify the inherited one
child.inherited = 100;
console.log(child.inherited); // 100 (own property)
console.log(parent.inherited); // 42 (unchanged)

The hasOwnProperty Check

Distinguishing between own properties and inherited properties is critical.

const base = { id: 1, type: 'base' };
const derived = Object.create(base);
derived.id = 2;
 
console.log(derived.hasOwnProperty('id'));   // true (own)
console.log(derived.hasOwnProperty('type')); // false (inherited)
console.log('type' in derived);              // true (checks whole chain)

ES6 Class Syntax and Prototypes

ES6 classes are syntactic sugar over the prototype system. They don't introduce a new inheritance model — they make the existing one more readable.

class Vehicle {
  constructor(public make: string, public model: string) {
    this.make = make;
    this.model = model;
  }
 
  start() {
    return `${this.make} ${this.model} started`;
  }
 
  static compare(a: Vehicle, b: Vehicle) {
    return a.make.localeCompare(b.make);
  }
}
 
class Car extends Vehicle {
  constructor(make: string, model: string, public doors: number) {
    super(make, model);
    this.doors = doors;
  }
 
  honk() {
    return `${this.make} ${this.model}: Beep!`;
  }
 
  start() {
    return `${super.start()} with ${this.doors} doors`;
  }
}
 
const tesla = new Car('Tesla', 'Model 3', 4);
console.log(tesla.start()); // "Tesla Model 3 started with 4 doors"
console.log(tesla.honk());  // "Tesla Model 3: Beep!"
 
// Under the hood: prototypes are still at work
console.log(Object.getPrototypeOf(Car) === Vehicle); // true
console.log(Object.getPrototypeOf(tesla) === Car.prototype); // true

ES6 Classes and Prototypes

Real-World Use Cases

Use Case 1: Mixin Pattern

Mixins allow objects to share behavior without classical inheritance hierarchies.

const Serializable = {
  serialize() {
    return JSON.stringify(this);
  },
  deserialize(json: string) {
    return Object.assign(Object.create(Object.getPrototypeOf(this)), JSON.parse(json));
  }
};
 
const Loggable = {
  log(message: string) {
    console.log(`[${this.constructor.name}] ${message}`);
  }
};
 
class User {
  constructor(public name: string) {}
 
  greet() {
    return `Hello, ${this.name}`;
  }
}
 
// Apply mixins
Object.assign(User.prototype, Serializable, Loggable);
 
const user = new User('Alice');
user.log('created');       // "[User] created"
console.log(user.serialize()); // '{"name":"Alice"}'

Use Case 2: Factory Pattern with Prototype Configuration

function createValidator(rules: Record<string, (val: any) => boolean>) {
  const validator = {
    errors: [] as string[],
 
    validate(data: Record<string, any>) {
      this.errors = [];
      for (const [field, rule] of Object.entries(rules)) {
        if (!rule(data[field])) {
          this.errors.push(`Validation failed for field: ${field}`);
        }
      }
      return this.errors.length === 0;
    },
 
    getErrors() {
      return [...this.errors];
    }
  };
 
  return Object.create(validator);
}
 
const emailValidator = createValidator({
  email: (val) => typeof val === 'string' && val.includes('@'),
  name: (val) => typeof val === 'string' && val.length > 0,
});
 
const valid = emailValidator.validate({ email: 'test@example.com', name: 'Alice' });
console.log(valid); // true

Use Case 3: Performance-Optimized Method Sharing

In applications with thousands of instances, shared prototype methods save significant memory compared to defining methods in constructors.

class Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
 
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
    this.vx = 0;
    this.vy = 0;
  }
 
  // Shared across ALL Particle instances via prototype
  update(dt: number) {
    this.x += this.vx * dt;
    this.y += this.vy * dt;
  }
 
  distanceTo(other: Particle) {
    return Math.sqrt((this.x - other.x) ** 2 + (this.y - other.y) ** 2);
  }
}
 
// 10,000 particles share ONE copy of update() and distanceTo()
const particles = Array.from({ length: 10000 }, () => new Particle(
  Math.random() * 800,
  Math.random() * 600
));

Best Practices for Production

  1. Use Object.getPrototypeOf() instead of __proto__: The __proto__ accessor is deprecated and has performance implications. Always use the standard method.

  2. Prefer Object.create(null) for pure dictionaries: When creating lookup objects that shouldn't have inherited properties like toString, Object.create(null) gives you a truly empty prototype.

  3. Use ES6 classes for new code: They provide clearer syntax, enforce new usage, and support super natively. But remember — they're prototypes underneath.

  4. Avoid deep prototype chains: Deep inheritance hierarchies are hard to reason about and slow down property lookups. Prefer composition over deep inheritance.

  5. Always set constructor when overriding prototype: If you replace a constructor's prototype entirely, set the constructor property back to maintain consistency.

  6. Use hasOwnProperty for object iteration: When using for...in loops, always check hasOwnProperty to avoid iterating over inherited properties.

  7. Freeze prototype objects when appropriate: Use Object.freeze() on shared prototype objects to prevent accidental mutation.

  8. Understand the performance implications: Prototype method lookups are slightly slower than own property access. For hot paths with millions of iterations, consider caching method references.

Common Pitfalls and Solutions

PitfallImpactSolution
Shared reference properties on prototypeAll instances share the same array/objectInitialize reference types in the constructor, not on the prototype
Missing super() call in subclassthis is undefined in constructorAlways call super() before using this in a derived class
Using for...in without hasOwnPropertyIterates inherited propertiesAlways guard with hasOwnProperty or use Object.keys()
Overriding prototype after defining methodsMethods lostSet up inheritance before adding methods to the prototype
Assuming new optional with classesTypeError: class constructors must be invoked with newES6 classes require new — this is intentional

Performance Optimization

Prototype Method Caching

For performance-critical loops, caching prototype methods avoids repeated lookups.

class OptimizedRenderer {
  render(items: Item[]) {
    // Cache the method reference
    const draw = this.drawItem;
 
    for (let i = 0; i < items.length; i++) {
      draw.call(this, items[i]);
    }
  }
 
  drawItem(item: Item) {
    // Expensive rendering logic
  }
}

Object Shape Optimization

V8 and other engines optimize objects based on their "shape" (hidden classes). Keeping prototype chains short and object shapes consistent improves property access speed.

// BAD: Dynamic properties cause shape changes
function createUser(name: string, includeAge: boolean) {
  const user = { name };
  if (includeAge) {
    user.age = 0; // Changes object shape mid-creation
  }
  return user;
}
 
// GOOD: Consistent shape from the start
function createUserOptimized(name: string, includeAge: boolean) {
  return {
    name,
    age: includeAge ? 0 : undefined,
  };
}

Memory: Prototype vs Instance Methods

// BAD: Each instance gets its own copy of the function
function BadExample() {
  this.method = function() { return 42; };
}
 
// GOOD: One shared function on the prototype
function GoodExample() {}
GoodExample.prototype.method = function() { return 42; };
 
// With 10,000 instances:
// BadExample: 10,000 function objects in memory
// GoodExample: 1 function object in memory

Comparison with Alternatives

FeaturePrototypesES6 ClassesObject.createComposition
ReadabilityLowHighMediumHigh
InheritanceManual setupextends keywordExplicit linkingNo inheritance
Multiple inheritanceNot supportedNot supportedNot supportedNatural fit
Memory efficiencyShared methodsShared methodsShared methodsPer-instance
Constructor supportManualBuilt-inNoneNone
TypeScript supportLimitedFullLimitedFull

Advanced Patterns

Parasitic Combination Inheritance

The most robust inheritance pattern that avoids calling the parent constructor twice.

function inheritPrototype(child: Function, parent: Function) {
  const prototype = Object.create(parent.prototype);
  prototype.constructor = child;
  child.prototype = prototype;
}
 
function Shape(color: string) {
  this.color = color;
}
 
Shape.prototype.describe = function() {
  return `A ${this.color} shape`;
};
 
function Rectangle(color: string, width: number, height: number) {
  Shape.call(this, color);
  this.width = width;
  this.height = height;
}
 
inheritPrototype(Rectangle, Shape);
 
Rectangle.prototype.area = function() {
  return this.width * this.height;
};

Proxy-Based Property Access Logging

function withPropertyLogging(obj: object) {
  return new Proxy(obj, {
    get(target, prop, receiver) {
      console.log(`Accessed property: ${String(prop)}`);
      return Reflect.get(target, prop, receiver);
    },
    has(target, prop) {
      console.log(`Checked property: ${String(prop)}`);
      return Reflect.has(target, prop);
    }
  });
}
 
const loggedUser = withPropertyLogging(new User('Alice'));
loggedUser.name; // Logs: "Accessed property: name"

Testing Strategies

describe('Prototype inheritance', () => {
  it('should traverse the prototype chain', () => {
    const parent = { value: 1 };
    const child = Object.create(parent);
    child.value = 2;
 
    expect(child.value).toBe(2); // Own property
    delete child.value;
    expect(child.value).toBe(1); // Falls through to parent
  });
 
  it('should support instanceof checks', () => {
    const rex = new Dog('Rex', 'Shepherd');
    expect(rex instanceof Dog).toBe(true);
    expect(rex instanceof Animal).toBe(true);
  });
 
  it('should share prototype methods across instances', () => {
    const a = new Person('Alice', 30);
    const b = new Person('Bob', 25);
 
    expect(a.greet).toBe(b.greet); // Same function reference
    expect(a.hasOwnProperty('greet')).toBe(false);
  });
 
  it('should allow hasOwnProperty to distinguish own vs inherited', () => {
    const child = Object.create({ inherited: true });
    child.own = true;
 
    expect(child.hasOwnProperty('own')).toBe(true);
    expect(child.hasOwnProperty('inherited')).toBe(false);
  });
});

Future Outlook

The TC39 pipeline operator (|>) and pattern matching proposals may change how we compose object behaviors. The Decorators proposal (Stage 3) brings annotation-based metaprogramming that works naturally with class prototypes. Meanwhile, the Records and Tuples proposal introduces deeply immutable value types that complement the prototype system.

TypeScript continues to refine its class and prototype support, with features like satisfies, template literal types, and improved inference making class-based APIs more expressive. The future is one where prototype power meets class ergonomics.

Prototype Chain Debugging

Debug prototype chain issues using Object.getPrototypeOf() to traverse the chain programmatically. Use Object.getOwnPropertyNames() and Object.getOwnPropertyDescriptor() to distinguish between own properties and inherited properties. Chrome DevTools shows the full prototype chain in the Properties panel when inspecting an object. Use hasOwnProperty() or Object.hasOwn() to check if a property belongs directly to an object rather than its prototype. Understanding prototype chains is essential for debugging issues with library inheritance patterns and framework component hierarchies.

Prototype Patterns in Modern Frameworks

Modern JavaScript frameworks use prototypes extensively under the hood. React's class components use prototype methods for lifecycle hooks like componentDidMount and componentDidUpdate. Vue.js 2's Options API stores methods and computed properties on component prototypes. Understanding prototypes helps debug issues when extending framework classes or when methods don't behave as expected due to prototype chain ordering. Use Object.create() to implement clean inheritance hierarchies that work with both classical and modern JavaScript patterns.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

JavaScript's prototype system is the foundation of the language's object model. While ES6 classes provide cleaner syntax, understanding the underlying prototype mechanics is essential for debugging, performance optimization, and designing flexible APIs.

Key takeaways:

  1. Every object has a prototype — the chain ends at null
  2. Property lookup traverses the chain — own properties take priority
  3. ES6 classes are syntactic sugar — they use prototypes internally
  4. Object.create gives precise control — over prototype linking
  5. Shared prototype methods save memory — one function, many instances
  6. Prefer composition over deep inheritance — simpler, more flexible
  7. Always check hasOwnProperty — when iterating with for...in

Master the prototype chain, and you'll understand JavaScript at its deepest level. Every class, every method call, every instanceof check — it's all prototypes underneath.