Objects and prototypes

Object literal forms, descriptors, prototype traversal, destructuring, property order, JSON, and object types.

High-level overview

This page explains what JavaScript does with a property read such as obj.x. JavaScript first looks for x on obj; when obj does not have that property, JavaScript continues through obj’s prototype chain. Property writes, copies, enumeration, destructuring, and JSON serialization use different rules from a property read.

For every object operation, identify the object that supplies the property, the kind of descriptor on that property, and the set of keys that the operation is allowed to visit.

Things to focus on

// 03 — OBJECTS, PROPERTIES, PROTOTYPES, DESTRUCTURING
//
// A reading file. Read it straight down: the comment above each construct gives
// its NAME, and `// =>` gives what the expression on its left evaluates to, so
// nothing is left hanging. Each section uses only what the lines just above it
// introduced.
//
// It still runs (`node 03-objects-prototypes-destructuring.ts`) and prints
// nothing; the `// =>` annotations are the output, already collected.
//
// Mark anything you can't explain out loud with  // ?

// ─── 1. OBJECT LITERAL SYNTAX ────────────────────────────────────────────────
//
// An object literal has more shapes than most people use. Worth knowing all of
// them, because you will read them — and because two of them (method shorthand
// and `__proto__`) behave differently from what they look like.

const x = 1;
const key = "dynamic";

const literal = {
  plain: 1,
  "quoted key": 2, // any string can be a key
  123: 3, // a numeric key becomes the STRING "123"
  x, // SHORTHAND — exactly the same as `x: x`
  [key]: 4, // COMPUTED KEY — the name is an expression, evaluated when the object is built
  [`${key}Template`]: 5, // computed from a template
  method() { // METHOD SHORTHAND. NOT sugar for `method: function(){}` — it remembers
    return "method"; // which object it was written in, which is what makes `super` work.
  }, // In exchange it cannot be `new`ed and has no .prototype.
  get accessor() { // a getter and a setter of the same name form ONE property
    return "get";
  },
  set accessor(v: string) {
    void v;
  },
  async asyncMethod() {
    return "async";
  },
  *generatorMethod() {
    yield 1;
  },
  [Symbol.iterator]() { // a SYMBOL KEY — always computed, since there is no literal
    return [1, 2][Symbol.iterator](); // syntax for a symbol
  },
};

const plainRead = literal.plain; // => 1
const quotedRead = literal["quoted key"]; // => 2 — dot notation can't spell this one
const numericRead = literal[123]; // => 3 — and literal["123"] finds the same property
const computedRead = literal.dynamic; // => 4 — the key was computed, the access is ordinary
const templateRead = literal.dynamicTemplate; // => 5
const methodRead = literal.method(); // => "method"
const accessorRead = literal.accessor; // => "get" — a call wearing a field's clothes
const symbolKeys = Object.getOwnPropertySymbols(literal); // => [Symbol(Symbol.iterator)]

// ─── 2. SPREAD AND REST ON OBJECTS ───────────────────────────────────────────
//
// Object spread copies PROPERTIES, one level deep, by reading them. Both halves
// of that sentence cause surprises, and both are below.

const base = { a: 1, b: 2 };
const extended = { ...base, b: 99, c: 3 }; // => { a: 1, b: 99, c: 3 } — last one wins
const { a, ...others } = extended; // the same three dots, gathering instead of spreading
const pulledOut = a; // => 1
const leftOver = others; // => { b: 99, c: 3 }

// "By reading them" means a getter RUNS, right now, and what lands in the copy is
// its result — a snapshot at that moment, not a frozen value. The copied property
// is an ordinary writable data property; the accessor itself does not come along.
const getterCalls: string[] = [];
const withGetter = {
  get computed() {
    getterCalls.push("invoked");
    return 1;
  },
};
const spreadOnce = { ...withGetter };
const timesInvoked = getterCalls.length; // => 1 — the spread called it
const copiedDescriptor = Object.getOwnPropertyDescriptor(spreadOnce, "computed");
// => { value: 1, writable: true, enumerable: true, configurable: true }
// A plain data property. The getter did not survive the copy; its answer did.

// "One level deep" means the nested object is the SAME object, not a copy. This
// is the bug behind most "why did editing my copy change the original" questions.
const nested = { inner: { deep: 1 } };
const shallow = { ...nested };
shallow.inner.deep = 999;
const originalChanged = nested.inner.deep; // => 999 — one object, reached two ways
const topLevelIndependent = shallow !== nested; // => true — only the outer object is new

// ─── 3. DESTRUCTURING, EVERY FORM ────────────────────────────────────────────
//
// The left side of these is a PATTERN, not an expression. Object patterns use
// property access for named keys; array patterns consume an ITERATOR. Read each
// one asking: which names does this actually create?

const source = { a: 1, b: 2 };

const { a: renamed } = source; // => 1. Creates `renamed`; there is no variable `a` here.
const { missing = "default" } = source as { missing?: string }; // => "default"
const { inner: { deep } } = { inner: { deep: 7 } }; // => 7 — NESTED PATTERN. Note it
// creates `deep` and nothing called `inner`.
const [p, q = 10, ...tail] = [1]; // ARRAY PATTERN with a default and a rest
const pValue = p; // => 1
const qValue = q; // => 10 — the element was absent, so the default fired
const tailValue = tail; // => [] — a rest is always an array, even when empty
const [, skipped] = [1, 2]; // => 2 — ELISION: the bare comma skips index 0 unnamed

let m = 1, n = 2;
[m, n] = [n, m]; // SWAP with no temporary variable
const swapped = [m, n]; // => [2, 1]

const { length } = "string"; // => 6 — OBJECT PATTERN property access temporarily wraps it

// DESTRUCTURING IN PARAMETERS, with a default for the whole object as well as
// for each property — which is what lets you call it with no argument at all.
function withDefaults({ a: da = 1, b: db = 2 } = {}) {
  return da + db;
}
const bothDefaulted = withDefaults(); // => 3
const oneSupplied = withDefaults({ a: 10 }); // => 12

// You can destructure into names that already exist. At statement position, a
// leading `{` starts a BLOCK, so parentheses force an ASSIGNMENT EXPRESSION.
let assigned: number;
({ a: assigned } = source);
const assignedValue = assigned; // => 1

const dynamicKey = "b";
const { [dynamicKey]: pulled } = source; // => 2 — a COMPUTED KEY in a pattern

const entryLog: string[] = [];
for (const [k, v] of Object.entries(source)) entryLog.push(`${k}=${v}`);
const entries = entryLog; // => ["a=1", "b=2"]

// ─── 4. PROPERTY DESCRIPTORS ─────────────────────────────────────────────────
//
// PROPERTY DESCRIPTOR — every property has enumerable and configurable, plus
// EITHER value + writable (DATA) OR get + set (ACCESSOR). You rarely inspect the
// record until a write is rejected or an enumeration omits a key.

const described = {};
Object.defineProperty(described, "hidden", {
  value: 42,
  writable: false, // writes are rejected — silently in sloppy code, loudly in strict
  enumerable: false, // invisible to for-in, Object.keys, spread, and JSON.stringify
  configurable: false, // cannot be deleted, and cannot be turned back on later
});
const hiddenDescriptor = Object.getOwnPropertyDescriptor(described, "hidden");
// => { value: 42, writable: false, enumerable: false, configurable: false }
const hiddenInKeys = Object.keys(described); // => [] — enumerable: false hides it
const hiddenInJson = JSON.stringify(described); // => "{}"
const hiddenViaIn = "hidden" in described; // => true — it is there; it just doesn't list
const hiddenViaNames = Object.getOwnPropertyNames(described); // => ["hidden"]
// DEFINEPROPERTY DEFAULTS — enumerable and configurable default to false. For a
// DATA descriptor, value defaults to undefined and writable to false. Literal
// data properties instead begin writable, enumerable, and configurable.

// ACCESSOR DESCRIPTOR — get/set instead of value/writable. A descriptor is one or
// the other, never both.
const accessorDefined = {};
Object.defineProperty(accessorDefined, "computed", {
  get() { return 42; },
  enumerable: true,
  configurable: true,
});
const accessorDescriptor = Object.getOwnPropertyDescriptor(accessorDefined, "computed");
// => { get: [Function: get], set: undefined, enumerable: true, configurable: true }
Object.defineProperties(accessorDefined, { plain: { value: 1, enumerable: true } });
const bothKeys = Object.keys(accessorDefined); // => ["computed", "plain"]

// Hold on to this distinction, it comes back constantly. ASSIGNMENT walks the
// prototype chain, so an inherited setter runs and an inherited read-only
// property blocks you. defineProperty ignores the chain and plants the property
// directly. They look alike and are not. See 25.2.
const descriptorMap = Object.getOwnPropertyDescriptors({ get g() { return 1; } });
// => { g: { get: [Function: get g], set: undefined, enumerable: true, configurable: true } }

// ─── 5. OBJECT STATICS ───────────────────────────────────────────────────────
//
// OBJECT STATIC METHODS — each operation chooses independently between own or
// inherited, enumerable or all, and string or symbol keys. There is no single
// "Object statics ignore symbols" rule.

const stock = { a: 1, b: 2 };

const stockKeys = Object.keys(stock); // => ["a", "b"] — own, enumerable, string keys only
const stockValues = Object.values(stock); // => [1, 2]
const stockEntries = Object.entries(stock); // => [["a", 1], ["b", 2]]
const rebuilt = Object.fromEntries([["k", "v"]]); // => { k: "v" } — builds from key/value pairs
// Unlike Object.entries, Object.fromEntries also accepts symbol keys; duplicate keys collapse.

const assignTarget = { a: 0 };
const assignResult = Object.assign(assignTarget, stock, { c: 3 }); // => { a: 1, b: 2, c: 3 }
const mutatedInPlace = assignTarget === assignResult; // => true — assign MUTATES the first
// argument and returns it. It copies own enumerable STRING AND SYMBOL keys. It
// also assigns, so setters fire and a rejected target write throws — unlike
// spread, which builds a new object and defines data properties.

const frozen = Object.freeze({ f: 1 });
const frozenCheck = Object.isFrozen(frozen); // => true — FREEZE prevents extensions,
// makes own properties non-configurable, and makes own DATA properties non-writable.
// It does not recursively freeze objects stored in those properties.
const sealed = Object.seal({ s: 1 });
const sealedCheck = Object.isSealed(sealed); // => true — SEAL prevents extensions and
// makes own properties non-configurable. It leaves each data property's writable
// flag unchanged; sealing does not guarantee that every existing property is editable.
const preventExtensions = Object.preventExtensions({ p: 1 });
const extensibleCheck = Object.isExtensible(preventExtensions); // => false
const plainIsExtensible = Object.isExtensible({}); // => true

const sameValueZero = Object.is(NaN, NaN); // => true — `NaN === NaN` is false
const signedZero = Object.is(0, -0); // => false — `0 === -0` is true
// Object.is differs from `===` on exactly those two cases, and nowhere else.

const grouped = Object.groupBy([1, 2, 3, 4], (n) => (n % 2 ? "odd" : "even"));
// => [Object: null prototype] { odd: [1, 3], even: [2, 4] }   (ES2024)
const groupedMap = Map.groupBy([1, 2], (n) => n > 1);
// => Map(2) { false => [1], true => [2] } — same idea, keyed by any value at all

// ─── 6. PROTOTYPES ───────────────────────────────────────────────────────────
//
// The whole model in one sentence: reading a property checks the object, then its
// prototype, then ITS prototype, until it runs out. That is inheritance here — a
// chain of ordinary objects, not a class hierarchy.

const proto = {
  greet(this: { name: string }) { // a `this` parameter types the receiver
    return `hello from proto, ${this.name}`;
  },
};

const child = Object.create(proto); // a new object whose chain starts at `proto`
child.name = "child";
const inherited = child.greet(); // => "hello from proto, child" — found one link up
const chainStart = Object.getPrototypeOf(child) === proto; // => true

// `super` works in plain objects too. METHOD DEFINITIONS — ordinary, async,
// generator, getter, and setter forms — receive a HOME OBJECT. A property whose
// value is `function () {}` does not.
const overrider = {
  __proto__: proto, // PROTOTYPE SETTER LITERAL — only this colon form is special
  greet(this: { name: string }) {
    return "wrapped: " + super.greet.call(this);
  },
  name: "overrider",
};
const overridden = overrider.greet(); // => "wrapped: hello from proto, overrider"
const magicKeyWorked = Object.getPrototypeOf(overrider) === proto; // => true
const notAProperty = Object.keys(overrider); // => ["greet", "name"] — no "__proto__"
const computedIsOrdinary = Object.keys({ ["__proto__"]: 1 }); // => ["__proto__"] — written
// as a computed key it is just a key again, with no magic at all

// An own property SHADOWS the prototype's. The prototype's version is not gone;
// you can still reach it explicitly.
child.greet = () => "shadowed";
const nowShadowed = child.greet(); // => "shadowed"
const stillReachable = proto.greet.call(child); // => "hello from proto, child"

const inChain = "greet" in child; // => true — `in` walks the whole chain
const isOwn = Object.hasOwn(child, "greet"); // => true — it was just assigned above
const inheritedNotOwn = Object.hasOwn(child, "toString"); // => false — that lives on
// Object.prototype, several links up

const arrayLink = Object.getPrototypeOf([]) === Array.prototype; // => true
const nextLink = Object.getPrototypeOf(Array.prototype) === Object.prototype; // => true
const endOfChain = Object.getPrototypeOf(Object.prototype); // => null — every chain ends

// NO PROTOTYPE AT ALL. Nothing inherited means nothing to collide with, which is
// what you want for a dictionary of keys you did not choose. See 32.5.
const bare = Object.create(null);
bare.safe = "no inherited keys";
const bareProto = Object.getPrototypeOf(bare); // => null
const bareHasNoToString = "toString" in bare; // => false — on `{}` this would be true

// Changing a prototype after creation is legal and slow: engines optimise for a
// fixed shape. Build the object with the prototype you want instead.
const relinked = Object.setPrototypeOf({ own: 1 }, proto);
const relinkedOwn = relinked.own; // => 1
const relinkedInherits = "greet" in relinked; // => true

// ─── 7. PROPERTY ORDER ───────────────────────────────────────────────────────
//
// ORDINARY OWN-KEY ORDER — array-index strings (canonical integers from 0 through
// 2^32 - 2) come first numerically. Other strings retain insertion order, then
// symbols retain insertion order. APIs such as Object.keys omit the symbol part.

const ordered = { b: 1, 2: 2, a: 3, 1: 4, [Symbol("s")]: 5 };
const orderedKeys = Object.keys(ordered); // => ["1", "2", "b", "a"]

// ─── 8. DELETE, OPTIONAL CHAINING, JSON ──────────────────────────────────────

const deletable: { gone?: number; kept: number } = { gone: 1, kept: 2 };
const deleteResult = delete deletable.gone; // => true — reports whether the key is gone,
const afterDelete = deletable; // => { kept: 2 } // not whether it was ever there

const sparse: { a?: { b?: { c?: number } } } = {};
const chainedRead = sparse?.a?.b?.c; // => undefined — the whole chain stops at the first
const bracketForm = sparse.a?.["b"]; // => undefined // nullish link
const withFallback = sparse.a?.b?.c ?? "fallback"; // => "fallback"

const jsonSource = {
  n: 1,
  s: "s",
  nested: { d: [1, 2] },
  undef: undefined, // silently dropped
  fn() {}, // silently dropped
  sym: Symbol("s"), // silently dropped
  toJSON() { // if an object has this, JSON.stringify serialises what it RETURNS instead
    return { replaced: true };
  },
};
const serialised = JSON.stringify(jsonSource); // => '{"replaced":true}' — toJSON won, and
// nothing else in the object was ever consulted

const withoutToJson = JSON.stringify({ n: 1, undef: undefined, fn() {} });
// => '{"n":1}' — undefined and function-valued object properties are omitted
const filtered = JSON.stringify({ a: 1, b: 2 }, ["a"], 2); // => '{\n  "a": 1\n}'
// REPLACER ARRAY — selects property names and their order; it can retrieve even
// inherited or non-enumerable properties. The third argument is the indent.
const revived = JSON.parse('{"a":1}', (k, v) => (k === "a" ? v * 2 : v)); // => { a: 2 }
// REVIVER — runs after parsing, children before parents, then once for the root.

// STRUCTURED CLONE — deeply clones the supported value graph, including cycles,
// Maps, Dates, and typed arrays. It throws on functions. It does NOT preserve
// custom prototypes, descriptors, accessors, non-enumerable keys, or symbol keys.
const cyclic: Record<string, unknown> = { name: "cycle" };
cyclic.self = cyclic;
const clone = structuredClone(cyclic);
const cycleSurvived = clone.self === clone; // => true — the cycle was rebuilt, not followed
const cloneIsSeparate = clone !== cyclic; // => true

// ─── 9. TYPESCRIPT'S VIEW OF OBJECTS ─────────────────────────────────────────
//
// TYPESCRIPT OBJECT TYPES — interfaces, aliases, annotations, index signatures,
// and readonly modifiers are erased. The ordinary const declarations and reads
// in this section still execute at runtime.

interface Shape { // an INTERFACE is open: declare it again and the members merge
  readonly id: string; // `readonly` stops you reassigning it, and freezes nothing (40.2)
  size?: number; // OPTIONAL — may be absent entirely
  [extra: string]: unknown; // INDEX SIGNATURE — constrains every string property that exists
}

type Alias = { // a TYPE ALIAS is closed, but can name unions and primitives too
  a: number;
};

const shape: Shape = { id: "1", anything: true };
const idRead = shape.id; // => "1"
const sizeRead = shape.size; // => undefined — declared optional, never supplied
const extraRead = shape.anything; // => true — the index signature admits it

// FRESHNESS (excess property checking). A fresh literal in a typed context gets
// an extra check for undeclared properties. Losing freshness removes that extra
// check, but ordinary structural assignability still applies. Nested literals can
// receive their own freshness checks; this is not exact-object typing.
const fresh: Alias = { a: 1 };
const viaVariable = { a: 1, extra: 2 };
const notFresh: Alias = viaVariable; // no error: the literal is no longer fresh
const carriedAlong = (notFresh as typeof viaVariable).extra; // => 2 — `extra` was there
// the whole time. The type said nothing about it; it did not remove it.