Skip to content

Objects and Prototypes

An object is a collection of key/value pairs where keys are strings or symbols. Objects are handled by reference, and they inherit from other objects through a hidden link called the prototype — the mechanism that class syntax is built on top of.

const user = {
name: 'Ada',
age: 36,
'multi word': true, // quotes needed for non-identifier keys
greet() { // method shorthand
return `Hi ${this.name}`;
},
};

Keys are always strings or symbols. Any other key is converted to a string:

const obj = { 1: 'one', true: 'yes' };
Object.keys(obj); // => ['1', 'true']
obj[1] === obj['1']; // => true
user.name; // dot notation — fixed, identifier-like key
user['multi word']; // bracket notation — any string, or a variable
const key = 'age';
user[key]; // => 36
user.missing; // => undefined (no error)
user.a?.b; // => undefined (optional chaining guards the next link)
const name = 'Ada';
const age = 36;
const person = { name, age }; // => { name: 'Ada', age: 36 }
const field = 'status';
const record = {
[field]: 'active',
[`${field}_at`]: Date.now(),
};
// => { status: 'active', status_at: 1754... }
user.email = 'ada@example.com'; // add
delete user.age; // remove
'name' in user; // => true (includes inherited keys)
Object.hasOwn(user, 'name'); // => true (own keys only; ES2022)
user.hasOwnProperty('name'); // => true (older equivalent)
const o = { a: 1, b: 2 };
Object.keys(o); // => ['a', 'b']
Object.values(o); // => [1, 2]
Object.entries(o); // => [['a', 1], ['b', 2]]
Object.fromEntries([['a', 1], ['b', 2]]); // => { a: 1, b: 2 }

Key order is specified: integer-like keys first in ascending numeric order, then string keys in insertion order, then symbols.

Object.keys({ b: 1, 2: 2, a: 3, 1: 4 }); // => ['1', '2', 'b', 'a']

Primitives are copied by value. Objects are copied by reference — the variable holds a pointer, and assignment copies the pointer, not the data.

let a = 1;
let b = a;
b = 2;
a; // => 1 (independent)
const x = { n: 1 };
const y = x;
y.n = 2;
x.n; // => 2 (same object)
x === y; // => true

This is the single most common source of “why did my data change?” bugs. It applies to function arguments too:

function mutate(obj) { obj.changed = true; }
function reassign(obj) { obj = { fresh: true }; } // only rebinds the local parameter
const target = {};
mutate(target); // target => { changed: true }
reassign(target); // target unchanged

The object is passed by sharing: the function gets a copy of the reference. It can mutate what the reference points at, but reassigning the parameter has no effect outside.

{ ...obj } and Object.assign copy own enumerable properties one level deep. Nested objects are still shared.

const original = { name: 'Ada', tags: ['a', 'b'] };
const copy = { ...original };
copy.name = 'Grace'; // independent
copy.tags.push('c'); // shared! original.tags is now ['a','b','c']
Object.assign({}, original); // same as spread
Object.assign(target, src1, src2); // mutates target, later sources win
const withOverride = { ...original, name: 'Grace' }; // later keys win

structuredClone (built into modern browsers and Node 17+) creates a genuine deep copy using the structured clone algorithm.

const original = { name: 'Ada', nested: { tags: ['a'] }, when: new Date() };
const deep = structuredClone(original);
deep.nested.tags.push('b');
original.nested.tags; // => ['a'] — untouched
deep.when instanceof Date; // => true

It handles cycles, Date, RegExp, Map, Set, ArrayBuffer, and typed arrays. It throws on functions, symbols, DOM nodes, and class instances lose their prototype (they come back as plain objects).

structuredClone({ fn: () => {} }); // DataCloneError

The old JSON.parse(JSON.stringify(obj)) trick still works for pure JSON data but silently drops undefined, functions, and symbols, turns Date into a string, and throws on cycles.

Every property has a descriptor: either a data descriptor (value, writable) or an accessor descriptor (get, set), plus enumerable and configurable.

const o = { a: 1 };
Object.getOwnPropertyDescriptor(o, 'a');
// => { value: 1, writable: true, enumerable: true, configurable: true }

Properties created with defineProperty default every flag to false:

const config = {};
Object.defineProperty(config, 'version', {
value: '1.0',
writable: false,
enumerable: false,
configurable: false,
});
config.version = '2.0'; // silently ignored (TypeError in strict mode)
Object.keys(config); // => [] (not enumerable)
JSON.stringify(config); // => '{}'
config.version; // => '1.0'
Flag Meaning when false
writable assignment to the property is rejected
enumerable hidden from Object.keys, for..in, spread, JSON.stringify
configurable cannot be deleted or redefined (and writable cannot go back to true)

Accessor properties look like data but run a function.

const account = {
first: 'Ada',
last: 'Lovelace',
get fullName() {
return `${this.first} ${this.last}`;
},
set fullName(value) {
[this.first, this.last] = value.split(' ');
},
};
account.fullName; // => 'Ada Lovelace' (no parentheses)
account.fullName = 'Grace Hopper';
account.first; // => 'Grace'

Use them for computed values and validation:

const temp = {
_celsius: 0,
get fahrenheit() { return this._celsius * 9 / 5 + 32; },
set celsius(v) {
if (typeof v !== 'number') throw new TypeError('celsius must be a number');
this._celsius = v;
},
};

Three levels, from weakest to strongest:

const o = { a: 1, nested: { b: 2 } };
Object.preventExtensions(o); // no new properties; existing ones still writable/deletable
Object.seal(o); // + no deletions, no reconfiguration; values still writable
Object.freeze(o); // + values immutable
const frozen = Object.freeze({ a: 1, nested: { b: 2 } });
frozen.a = 99; // ignored (TypeError in strict mode)
delete frozen.a; // ignored
frozen.nested.b = 99; // WORKS — freeze is shallow
Object.isFrozen(frozen); // => true

A recursive deep freeze:

function deepFreeze(obj) {
for (const value of Object.values(obj)) {
if (value && typeof value === 'object') deepFreeze(value);
}
return Object.freeze(obj);
}

Every object has an internal link, [[Prototype]], to another object or null. When you read a property that the object does not own, the engine follows that link, and keeps following until it finds the property or reaches null. That is the prototype chain.

const animal = {
eats: true,
describe() { return `eats: ${this.eats}`; },
};
const rabbit = Object.create(animal);
rabbit.jumps = true;
rabbit.jumps; // => true (own)
rabbit.eats; // => true (from animal)
rabbit.describe(); // => 'eats: true' — `this` is still rabbit
Object.hasOwn(rabbit, 'eats'); // => false

The chain for a plain object literal is obj → Object.prototype → null. That is where toString, hasOwnProperty, and valueOf come from.

Object.getPrototypeOf({}) === Object.prototype; // => true
Object.getPrototypeOf(Object.prototype); // => null
Object.getPrototypeOf([]) === Array.prototype; // => true

Writes never traverse the chain. Assigning creates an own property that shadows the inherited one (unless the prototype defines a setter):

rabbit.eats = false;
animal.eats; // => true — unaffected

These two names are confused constantly.

  • obj.__proto__ — a legacy accessor for the object’s own prototype link. Every object has it. Deprecated; use Object.getPrototypeOf / Object.setPrototypeOf.
  • Fn.prototype — a plain property on functions. It is the object that will become the [[Prototype]] of instances created with new Fn(). A function’s prototype is not the function’s own prototype.
function Dog(name) { this.name = name; }
Dog.prototype.speak = function () { return `${this.name} barks`; };
const rex = new Dog('Rex');
rex.speak(); // => 'Rex barks'
Object.getPrototypeOf(rex) === Dog.prototype; // => true
rex.__proto__ === Dog.prototype; // => true (same thing, legacy)
Dog.prototype.constructor === Dog; // => true

What new Dog('Rex') does, in order:

  1. Create an empty object.
  2. Set its [[Prototype]] to Dog.prototype.
  3. Call Dog with this bound to that object.
  4. Return that object — unless the constructor explicitly returns a different object.

Object.create(null) makes an object with no prototype — a clean dictionary with no inherited keys, safe for arbitrary user-supplied keys.

const dict = Object.create(null);
dict.toString; // => undefined
'toString' in dict; // => false

class is syntax over the prototype system. Methods go on Class.prototype; there is no new inheritance mechanism underneath.

class Animal {
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} makes a sound`;
}
}
const a = new Animal('Cat');
a.speak(); // => 'Cat makes a sound'
Object.getPrototypeOf(a) === Animal.prototype; // => true
typeof Animal; // => 'function'

Class bodies are always strict mode, and classes are not hoisted for use — they sit in the TDZ, so new Foo() before the declaration throws. Calling a class without new also throws.

class Dog extends Animal {
constructor(name, breed) {
super(name); // must run before using `this`
this.breed = breed;
}
speak() {
return `${super.speak()} — a bark`; // call the parent method
}
}
const d = new Dog('Rex', 'terrier');
d.speak(); // => 'Rex makes a sound — a bark'
d instanceof Dog; // => true
d instanceof Animal; // => true

extends wires two chains: instance methods (Dog.prototype → Animal.prototype) and statics (Dog → Animal).

Fields, static members, and private fields

Section titled “Fields, static members, and private fields”
class Counter {
count = 0; // instance field, set before the constructor body
#secret = 'hidden'; // private field
static instances = 0; // static field
static #registry = new Map(); // static private field
constructor() {
Counter.instances++;
}
increment() {
this.count++;
this.#log();
return this.count;
}
#log() { // private method
// only callable from inside the class
}
get doubled() { // getter
return this.count * 2;
}
static create() { // static method — called on the class
return new Counter();
}
static { // static initialization block (ES2022)
this.#registry.set('init', true);
}
}
const c = Counter.create();
c.increment(); // => 1
c.doubled; // => 2
Counter.instances; // => 1
c.#secret; // SyntaxError — not accessible outside the class body

# fields are hard private, enforced by the language: not visible in Object.keys, JSON.stringify, or a debugger’s property list, and inaccessible even via bracket notation. This is unlike the _underscore convention, which is only a hint.

The in operator does a safe brand check on private fields:

class Point {
#x = 0;
static isPoint(o) { return #x in o; }
}
Point.isPoint(new Point()); // => true
Point.isPoint({}); // => false

x instanceof C walks x’s prototype chain looking for C.prototype.

[] instanceof Array; // => true
[] instanceof Object; // => true (Array.prototype inherits from Object.prototype)
// Equivalent to:
Object.prototype.isPrototypeOf.call(Array.prototype, []); // => true

Extending Error is the main practical use of extends in application code:

class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
try {
throw new ValidationError('email', 'must contain @');
} catch (err) {
if (err instanceof ValidationError) {
console.log(err.name, err.field, err.message);
// => ValidationError email must contain @
}
}
  • Object keys are strings or symbols; integer-like keys enumerate first, then string keys in insertion order.
  • Objects are handled by reference. Assignment, function arguments, and shallow copies all share nested data.
  • { ...obj } and Object.assign are one level deep; structuredClone is a real deep copy but throws on functions and drops class prototypes.
  • Descriptors control writable, enumerable, and configurable; defineProperty defaults them all to false.
  • Object.freeze is shallow — write deepFreeze if you need more.
  • Property reads walk the prototype chain; writes always create an own property.
  • __proto__ is an object’s own prototype link; Fn.prototype is what instances of Fn will inherit from. Never reassign prototypes at runtime.
  • class is prototype syntax: methods on the prototype, fields per instance, # fields genuinely private, super() required before this in a derived constructor.
  • instanceof breaks across realms; use Array.isArray for arrays.