Todos los artículos
javascript prototype poo herencia

Prototipos y herencia en JavaScript: lo que las clases no te muestran

El sistema de prototipos es el corazón de JavaScript. Entender cómo funciona te ayuda a razonar sobre herencia, composición, performance y esos bugs que parecen imposibles.

beresiartejuan

@beresiartejuan

Autor

4 de marzo de 2026 13 min de lectura

Si venís de Java, C#, Python o cualquier lenguaje con herencia clásica, las class de JavaScript te van a parecer familiares. Y eso, paradójicamente, es un problema. Porque se ven iguales pero no funcionan igual. Debajo de las clases de ES6 no hay un sistema de clases — hay una cadena de prototipos.

Esto no es un defecto del lenguaje. Es un modelo diferente, y cuando lo entendés, muchas cosas que antes parecían raras empiezan a tener sentido.

Lo primero: JavaScript no copia, enlaza

En lenguajes con herencia clásica, cuando una clase hereda de otra, los métodos del padre se copian (conceptualmente) al hijo. En JavaScript no. Hay una cadena de referencias entre objetos.

Mirá este ejemplo con class:

class Animal {
  hablar() {
    return "...";
  }
}

class Perro extends Animal {
  ladrar() {
    return "¡Guau!";
  }
}

const rex = new Perro();
console.log(rex.ladrar()); // "¡Guau!"
console.log(rex.hablar()); // "..."

rex no tiene el método hablar. Lo que tiene es una referencia interna (su prototipo) que apunta a Perro.prototype, que a su vez tiene una referencia que apunta a Animal.prototype, donde sí está hablar. Cuando llamás rex.hablar(), JavaScript sube la cadena buscando ese método hasta que lo encuentra (o llega a null).

Para ver que class es syntactic sugar, acá está el equivalente sin clases:

function Animal() {}
Animal.prototype.hablar = function () {
  return "...";
};

function Perro() {}
Perro.prototype = Object.create(Animal.prototype);
Perro.prototype.constructor = Perro;
Perro.prototype.ladrar = function () {
  return "¡Guau!";
};

const rex = new Perro();
console.log(rex.ladrar()); // "¡Guau!"
console.log(rex.hablar()); // "..."

Mismo resultado. Misma mecánica interna. Las class te dan una sintaxis más cómoda, pero no cambian el modelo.

La prototype chain en detalle

Cuando accedés a una propiedad de un objeto, JavaScript la busca siguiendo esta cadena:

  1. En el objeto mismo
  2. En su prototipo (Object.getPrototypeOf(obj))
  3. En el prototipo del prototipo
  4. Así sucesivamente, hasta llegar a null
const animal = {
  respirar() {
    return "inhala... exhala";
  },
};

const perro = Object.create(animal);
perro.ladrar = function () {
  return "¡Guau!";
};

const rex = Object.create(perro);
rex.nombre = "Rex";

console.log(rex.nombre);    // "Rex"       — propio
console.log(rex.ladrar());  // "¡Guau!"    — encontrado en 'perro'
console.log(rex.respirar());// "inhala..."  — encontrado en 'animal'
console.log(rex.toString());// "[object Object]" — encontrado en Object.prototype

La cadena completa es: rex → perro → animal → Object.prototype → null

El detalle clave: la búsqueda se hace en cada acceso a la propiedad. JavaScript no cachea el resultado. En la práctica esto no suele ser un problema de performance, pero es bueno saberlo — cadenas de prototipos muy largas sí pueden tener un costo medible en hot paths.

La distinción que confunde a todos: __proto__ vs .prototype

Estos dos conceptos se confunden constantemente, así que vamos a ser explícitos:

  • obj.__proto__ (o mejor, Object.getPrototypeOf(obj)) → es el prototipo de una instancia. Es la referencia que JavaScript sigue cuando buscás una propiedad que el objeto no tiene.
  • Constructor.prototype → es el objeto que JavaScript asigna como __proto__ a las instancias creadas con new Constructor().
function Vehiculo(marca) {
  this.marca = marca;
}

Vehiculo.prototype.arrancar = function () {
  return `${this.marca} arrancando...`;
};

const auto = new Vehiculo("Toyota");

// Estas dos cosas son el mismo objeto:
console.log(Object.getPrototypeOf(auto) === Vehiculo.prototype); // true

console.log(auto.arrancar()); // "Toyota arrancando..."

Cuando usás new Vehiculo("Toyota"), JavaScript hace esto internamente:

  1. Crea un objeto vacío {}
  2. Le asigna Vehiculo.prototype como su prototipo
  3. Ejecuta Vehiculo() con this apuntando al objeto nuevo
  4. Si la función no retorna explícitamente un objeto, retorna el nuevo

Entender esto te ayuda a debuggear situaciones raras. Por ejemplo, si olvidás el new, this dentro del constructor apunta a globalThis (o es undefined en strict mode) y todo se rompe de formas crípticas.

Object.create: herencia sin constructores

A veces no necesitás toda la ceremonia de un constructor. Object.create(proto) crea un objeto nuevo con el prototipo que vos le digas:

const animal = {
  tipo: "desconocido",
  describir() {
    return `Soy un ${this.tipo}`;
  },
};

const gato = Object.create(animal);
gato.tipo = "gato";
gato.maullar = function () {
  return "¡Miau!";
};

console.log(gato.describir()); // "Soy un gato"
console.log(gato.maullar());   // "¡Miau!"

// 'animal' no se modificó
console.log(animal.describir()); // "Soy un desconocido"

Un detalle que vale la pena notar: cuando hacés gato.tipo = "gato", estás creando una propiedad propia en gato. No estás modificando animal.tipo. Esto se llama property shadowing — la propiedad del objeto “tapa” la del prototipo.

Eso a veces genera confusión. Si la propiedad del prototipo es un objeto mutable (como un array), y lo modificás directamente sin shadowing, vas a estar mutando el prototipo para todas las instancias:

const base = {
  items: [],
};

const a = Object.create(base);
const b = Object.create(base);

a.items.push("dato"); // ⚠️ mutando el array del prototipo

console.log(b.items); // ["dato"] — b se vio afectado

La solución es inicializar propiedades mutables en cada instancia, no en el prototipo.

Composición sobre herencia: mixins

JavaScript no tiene herencia múltiple (y es mejor así — la herencia múltiple trae problemas conocidos como el “diamond problem”). Pero si necesitás combinar comportamientos de distintas fuentes, los mixins son un patrón directo:

const nadador = {
  nadar() {
    return `${this.nombre} nadando`;
  },
};

const volador = {
  volar() {
    return `${this.nombre} volando`;
  },
};

const corredor = {
  correr() {
    return `${this.nombre} corriendo`;
  },
};

class Pato {
  constructor(nombre) {
    this.nombre = nombre;
  }
}

Object.assign(Pato.prototype, nadador, volador);

const donald = new Pato("Donald");
console.log(donald.nadar()); // "Donald nadando"
console.log(donald.volar()); // "Donald volando"

Object.assign copia las propiedades enumerables de los objetos fuente al destino. En este caso, le agrega nadar y volar directamente a Pato.prototype.

El trade-off: perdés la cadena de prototipos para esos métodos (no podés hacer donald instanceof nadador). Pero en la práctica, si necesitás instanceof, probablemente estés pensando en términos demasiado orientados a clases. Revisar si un objeto tiene un método con 'nadar' in obj o typeof obj.nadar === 'function' suele ser suficiente.

Un enfoque más moderno que vale la pena conocer es el pattern de functional mixins:

function conLogger(clase) {
  return class extends clase {
    log(mensaje) {
      console.log(`[${this.constructor.name}] ${mensaje}`);
    }
  };
}

function conTimestamp(clase) {
  return class extends clase {
    get timestamp() {
      return new Date().toISOString();
    }
  };
}

class Base {
  constructor(nombre) {
    this.nombre = nombre;
  }
}

class Servicio extends conLogger(conTimestamp(Base)) {}

const s = new Servicio("API");
s.log(`Iniciado en ${s.timestamp}`);
// [Servicio] Iniciado en 2025-04-10T15:00:00.000Z

Es composable y mantiene la cadena de prototipos. Lo usamos bastante en casos donde queremos agregar funcionalidad transversal.

Iteración y propiedades heredadas

Un gotcha recurrente: for...in recorre propiedades propias y heredadas:

const base = { heredada: true };
const obj = Object.create(base);
obj.propia = true;

for (const key in obj) {
  console.log(key);
}
// "propia", "heredada" — ¿querías ambas?

Para filtrar solo las propias:

for (const key in obj) {
  if (Object.hasOwn(obj, key)) {
    console.log(key); // solo "propia"
  }
}

// O directamente, usar métodos que ya filtran:
Object.keys(obj);    // ["propia"]
Object.entries(obj);  // [["propia", true]]

Object.hasOwn(obj, key) es la forma moderna (ES2022) de hacer lo que antes se hacía con obj.hasOwnProperty(key). La ventaja es que funciona incluso si el objeto no tiene hasOwnProperty en su cadena (por ejemplo, si fue creado con Object.create(null)).

instanceof y sus limitaciones

instanceof verifica si el .prototype de un constructor aparece en algún punto de la cadena de prototipos del objeto:

class A {}
class B extends A {}
class C extends B {}

const c = new C();

console.log(c instanceof C);      // true
console.log(c instanceof B);      // true
console.log(c instanceof A);      // true
console.log(c instanceof Object); // true

Funciona bien la mayoría de las veces. Pero hay casos donde falla:

  • Objetos de distintos iframes/realms: cada iframe tiene su propio Array, Object, etc. Un array creado en un iframe no es instanceof Array del iframe padre.
  • Objetos creados con Object.create(null): no tienen Object.prototype en su cadena, así que obj instanceof Object retorna false.
  • Si modificás .prototype después de crear instancias: las instancias viejas pierden la relación con el nuevo prototipo.

Para type checking más robusto, a veces conviene typeof, Array.isArray(), o duck typing ('then' in obj para ver si es “thenable”).

Performance: por qué los métodos van en el prototype

Una razón práctica y no obvia: cuando definís métodos en el prototipo, todas las instancias comparten la misma función en memoria.

// ✅ Una sola copia de 'saludar' para todas las instancias
class Persona {
  constructor(nombre) {
    this.nombre = nombre;
  }
  saludar() {
    return `Hola, soy ${this.nombre}`;
  }
}
// ❌ Cada instancia crea su propia copia de 'saludar'
class Persona {
  constructor(nombre) {
    this.nombre = nombre;
    this.saludar = () => `Hola, soy ${this.nombre}`;
  }
}

Con 10 instancias no importa. Con 10.000, sí. El segundo patrón a veces se usa intencionalmente (para tener this bindeado automáticamente, por ejemplo en React class components), pero es un trade-off que hay que hacer conscientemente.

Para llevar

El sistema de prototipos no es algo que “deberías saber algún día”. Es lo que está pasando cada vez que escribís class, usás extends, o incluso .toString() en un objeto.

Puntos clave:

  • JavaScript no copia métodos entre clases — enlaza prototipos por referencia
  • La prototype chain es una lista enlazada de objetos, no una jerarquía copiada
  • class es syntactic sugar sobre function + .prototype
  • Composición con mixins suele ser más flexible que herencia profunda
  • for...in incluye propiedades heredadas (usá Object.keys o Object.hasOwn)
  • Los métodos en el prototipo se comparten en memoria; los definidos en el constructor, no

No es necesario que dejes de usar class. Las clases de ES6 están bien para la mayoría de los casos y la sintaxis es clara. Pero cuando algo no funciona como esperabas — un instanceof que falla, una propiedad que aparece donde no debería, un método que no se hereda — ahora sabés dónde mirar.