4. Objects, Arrays and Object-Oriented JavaScript
Apart from primitives, every value in JavaScript is an object, including arrays, functions, dates, maps and DOM nodes. This chapter covers how objects and collections are created, copied, iterated and serialised. It then covers how JavaScript implements object-oriented programming: classes, this, inheritance, and the prototype mechanism underneath them. Knowing these mechanics helps you predict when data is shared or copied, why a method loses its this, and what the class syntax actually does at runtime.
<a id="41-general-characteristics-of-objects"></a>
4.1 General Characteristics of Objects
This subchapter covers what an object is, how to create one and read its properties, how to list, copy, lock down and serialise objects, and the reference semantics behind all of these operations.
Object Literals and Property Access
Programs rarely deal with isolated values. A user has a name, an email address and a role. A product has a price, a stock level and a list of images. Keeping these values in separate variables (userName, userEmail, userRole) quickly becomes unmanageable. You need one value that groups related data under meaningful names. In JavaScript that value is the object.
Think of an object as a labelled filing cabinet. Each drawer has a label (the key) and holds something (the value). You can open a drawer by its label, add new drawers, relabel nothing, and throw drawers away. A key together with its value is called a property.
Creating objects with a literal. The most common way to create an object is an object literal: a list of key: value pairs inside braces.
const user = {
id: 42,
name: "Ada Lovelace",
"e-mail": "ada@example.com", // keys that are not valid identifiers must be quoted
roles: ["admin", "editor"], // values can be any type, including arrays...
address: { city: "London" }, // ...and other objects (nesting)
greet() { // a function-valued property is called a method
return `Hello, I am ${this.name}`;
},
};
Keys are always strings or symbols (3.1). If you write a number or another value as a key, it is converted to a string: { 1: "a" } has the key "1". Values can be anything.
Dot notation and bracket notation. There are two ways to read or write a property.
console.log(user.name); // "Ada Lovelace": dot notation, key written literally
console.log(user["name"]); // same property, bracket notation with a string
console.log(user["e-mail"]); // brackets are required: user.e-mail would mean user.e - mail
const field = "id";
console.log(user[field]); // 42: brackets evaluate an expression to get the key
console.log(user.field); // undefined: looks for a property literally called "field"
console.log(user.address.city); // "London": chained access into a nested object
console.log(user.greet()); // "Hello, I am Ada Lovelace"
Dot notation obj.key | Bracket notation obj[expr] | |
|---|---|---|
| Key | Written literally, must be a valid identifier | Any expression, converted to a string or symbol |
| Dynamic keys | No | Yes (obj[variable]) |
| Symbol keys | No | Yes (obj[sym]) |
| Readability | Preferred when the key is fixed | Use when the key is computed or contains special characters |
Missing properties. Reading a property that does not exist returns undefined. It does not throw. Reading a property of undefined or null does throw, which is the source of the familiar TypeError: Cannot read properties of undefined. Optional chaining (3.2) guards against this:
console.log(user.phone); // undefined: the property does not exist
// console.log(user.phone.number); // TypeError: reading a property of undefined
console.log(user.phone?.number); // undefined: optional chaining stops at undefined
Adding, changing and deleting. Objects are mutable: you can change them after creation, even when the variable is const (3.1). Assigning to a missing key creates it, and the delete operator removes a property entirely.
const product = { title: "Lamp" };
product.price = 39; // add a property
product.price = 35; // update it
delete product.price; // remove it: the key no longer exists
console.log(product); // { title: "Lamp" }
Testing whether a property exists. Comparing with undefined is not reliable, because a property can exist and hold undefined. The in operator checks whether the key is present (including inherited keys, see Property Enumeration and Ownership).
const settings = { theme: undefined };
console.log(settings.theme === undefined); // true
console.log(settings.font === undefined); // true: cannot tell the two cases apart
console.log("theme" in settings); // true: the key exists
console.log("font" in settings); // false
Memory layout. A variable never contains the object itself, only a reference to it. Nested objects are separate objects referenced from their parent.
user ----> +-------------------------+
| id: 42 |
| name: "Ada Lovelace" |
| roles: ref -----------+----> ["admin", "editor"]
| address: ref -----------+----> { city: "London" }
| greet: ref -----------+----> function greet() {...}
+-------------------------+
Key Takeaways
- An object is a mutable collection of properties. Each key is a string or a symbol, and each value can be of any type.
- Use dot notation for fixed, identifier-like keys and bracket notation for computed keys, symbols, or keys with special characters.
- Reading a missing property returns
undefined, but reading a property ofundefinedornullthrows. Use?.when a link in the chain may be missing. deleteremoves a property. Assigningundefinedkeeps the key.- Use
key in obj(orObject.hasOwn) to test whether a property exists, notobj.key === undefined.
Practice
- (Beginner) Create a
bookobject withtitle,author,yearand a nestedpublisherobject withnameandcity. Print the publisher's city using dot notation, then using only bracket notation.- (Intermediate) Write
getPath(obj, path)that takes a string such as"address.geo.lat"and returns the nested value, orundefinedif any link is missing. Do not useevalor optional chaining; use bracket notation in a loop.- (Interview) "What is the difference between
obj.key === undefined,'key' in objandObject.hasOwn(obj, 'key')?" Hint: build an object whose property holdsundefined, and another whose key is inherited from its prototype.
Computed and Shorthand Properties
Object literals are often built from variables you already have: the result of a form, the arguments of a function, a key chosen at runtime. Before ES2015 this meant repetitive code such as { name: name, age: age } and creating an empty object first whenever the key was dynamic. ES2015 added three small syntax features that remove most of this repetition: shorthand properties, shorthand methods and computed keys.
Shorthand properties. When a key has the same name as the variable holding its value, write the name once.
const name = "Ada";
const age = 36;
const verbose = { name: name, age: age }; // before ES2015
const short = { name, age }; // same object: { name: "Ada", age: 36 }
Shorthand methods. Methods can drop the : function part. Shorthand methods also cannot be used as constructors, and they can use super (4.3).
const counter = {
count: 0,
increment() { // instead of increment: function () { ... }
this.count++;
return this; // returning this allows chaining
},
};
counter.increment().increment();
console.log(counter.count); // 2
Computed property names. Wrapping a key in square brackets inside a literal makes it an expression. The expression is evaluated and converted to a string (or kept as a symbol), exactly as with bracket access.
const field = "email";
const lang = "en";
const id = Symbol("id");
const record = {
[field]: "ada@example.com", // key "email"
[`label_${lang}`]: "E-mail", // key "label_en": any expression is allowed
[id]: 123, // symbol key
["get" + "Name"]() { return "Ada"; } // computed method name: getName
};
console.log(record.email, record.label_en, record[id], record.getName());
// "ada@example.com" "E-mail" 123 "Ada"
A common use: updating state with a dynamic key. Form handlers, reducers and configuration builders often need to set "whichever field changed". Combining spread (3.2) with a computed key produces a new object without mutating the old one.
function updateField(form, fieldName, value) {
return { ...form, [fieldName]: value }; // copy all fields, then overwrite one
}
const form = { name: "", email: "" };
const next = updateField(form, "email", "ada@example.com");
console.log(next); // { name: "", email: "ada@example.com" }
console.log(form.email); // "": the original is unchanged
Duplicate keys and evaluation order. Properties are created from left to right. If two entries produce the same key, the later one wins. This is what makes the "defaults first, overrides last" pattern work.
const defaults = { theme: "light", fontSize: 14 };
const options = { ...defaults, theme: "dark" }; // later "theme" overwrites the earlier one
console.log(options); // { theme: "dark", fontSize: 14 }
let i = 0;
const obj = { [i++]: "a", [i++]: "b" }; // keys evaluated in order: "0", then "1"
console.log(obj); // { 0: "a", 1: "b" }
| Syntax | Example | Equivalent to |
|---|---|---|
| Shorthand property | { x, y } | { x: x, y: y } |
| Shorthand method | { run() {} } | { run: function () {} } (but not constructible, and can use super) |
| Computed key | { [k]: v } | const o = {}; o[k] = v; |
| Computed method | { [name]() {} } | Method whose name is the value of name |
Key Takeaways
{ x }is shorthand for{ x: x }.{ m() {} }is shorthand for a method.{ [expr]: value }evaluatesexprat creation time and uses the result as the key. This works for strings, numbers and symbols.- Keys are processed from left to right, and the last duplicate wins. Spread defaults first and overrides last.
{ ...obj, [key]: value }is the idiomatic way to produce an updated copy with a dynamic key.
Practice
- (Beginner) Rewrite
function makePoint(x, y) { return { x: x, y: y, toString: function () { return "(" + x + ", " + y + ")"; } }; }using shorthand properties, a shorthand method and a template literal.- (Intermediate) Write
indexBy(items, key)that turns[{ id: "a", v: 1 }, { id: "b", v: 2 }]into{ a: { id: "a", v: 1 }, b: { id: "b", v: 2 } }usingreduce, spread and a computed key. Then explain what happens if two items share the sameid.- (Interview) "Given
const k = { toString() { return 'x'; } }, what are the keys of{ [k]: 1 }, and what does({ [k]: 1 }).xreturn?" Hint: recall how a non-symbol value is converted when it is used as a property key.
Property Enumeration and Ownership
Many tasks need to visit every property of an object: rendering a table of settings, validating a form payload, copying or comparing objects. But "every property" is ambiguous. An object can see properties that it does not own because they come from its prototype (4.4). Some properties are hidden from loops on purpose (non-enumerable). Some keys are symbols, which most loops skip. Each enumeration tool makes a different choice on these three questions, so you need to know which one you are using.
Three questions about any property.
| Question | Values | Example |
|---|---|---|
| Is it own or inherited? | Own: stored on the object itself. Inherited: found on the prototype chain. | toString is inherited by {} from Object.prototype |
| Is it enumerable? | Enumerable properties appear in for...in and Object.keys. Non-enumerable ones are hidden. | Properties created by assignment are enumerable. Built-in methods are not. |
| Is the key a string or a symbol? | Symbol keys are skipped by most enumeration methods | Symbol.iterator on arrays |
const child = Object.create(parent) (4.4)
child.own = 1
child parent Object.prototype
+-----------+ [[Prototype]] +-------------+ ----> +------------------+ ----> null
| own: 1 | -------------> | inherited: 2| | toString (non- |
+-----------+ +-------------+ | enumerable) ... |
+------------------+
for...in child -> "own", "inherited" (own + inherited, enumerable, strings)
Object.keys(child) -> ["own"] (own, enumerable, strings)
The enumeration toolbox.
| Tool | Own | Inherited | Non-enumerable | Symbols |
|---|---|---|---|---|
for (k in obj) | Yes | Yes | No | No |
Object.keys(obj) / values / entries | Yes | No | No | No |
Object.getOwnPropertyNames(obj) | Yes | No | Yes | No |
Object.getOwnPropertySymbols(obj) | Yes | No | Yes | Only symbols |
Reflect.ownKeys(obj) | Yes | No | Yes | Yes |
JSON.stringify(obj) / { ...obj } / Object.assign | Yes | No | No | Spread and assign copy symbols, JSON skips them |
Object.keys, Object.values and Object.entries are the everyday choice. They give you arrays, which you can process with the array methods in 4.2, and they ignore inherited properties.
const scores = { ada: 9, linus: 7, grace: 10 };
for (const [name, score] of Object.entries(scores)) { // entries + destructuring (3.2)
console.log(`${name}: ${score}`);
}
const total = Object.values(scores).reduce((sum, s) => sum + s, 0);
console.log(total); // 26
// Transform an object: entries -> map -> fromEntries
const doubled = Object.fromEntries(
Object.entries(scores).map(([k, v]) => [k, v * 2])
);
console.log(doubled); // { ada: 18, linus: 14, grace: 20 }
Checking ownership. in answers "can this object see the key?", including inherited keys. Object.hasOwn(obj, key) (ES2022) answers "does this object itself have the key?". It replaces obj.hasOwnProperty(key), which fails on objects without a prototype and can be overridden by a property of the same name.
const base = { shared: true };
const item = Object.create(base); // item inherits from base (4.4)
item.local = 1;
console.log("shared" in item); // true: inherited
console.log(Object.hasOwn(item, "shared")); // false: not its own
console.log(Object.hasOwn(item, "local")); // true
for (const key in item) console.log(key); // "local", "shared": for...in includes inherited keys
const dict = Object.create(null); // an object with no prototype at all
dict.a = 1;
// dict.hasOwnProperty("a"); // TypeError: dict.hasOwnProperty is not a function
console.log(Object.hasOwn(dict, "a")); // true: works on any object
Property order. Own property keys are listed in a well-defined order, and the order is not simply insertion order:
- Keys that look like array indices (
"0","1","42"), in ascending numeric order. - Other string keys, in insertion order.
- Symbol keys, in insertion order (only for methods that return symbols).
const o = { b: 1, 10: 1, a: 1, 2: 1, [Symbol("s")]: 1 };
console.log(Object.keys(o)); // ["2", "10", "b", "a"]: integer-like keys first, sorted
console.log(Reflect.ownKeys(o)); // ["2", "10", "b", "a", Symbol(s)]
If you need a guaranteed insertion order for arbitrary keys, including numeric ones, use a Map (4.2).
Key Takeaways
- A property can be own or inherited, enumerable or not, and keyed by a string or a symbol. Each enumeration tool includes a different subset.
- Prefer
Object.keys,Object.valuesandObject.entries(own, enumerable, string keys) overfor...in, which also visits inherited keys. Reflect.ownKeyslists every own key, including non-enumerable properties and symbols.- Use
Object.hasOwn(obj, key)for ownership andkey in objfor visibility through the prototype chain. - Integer-like keys are always listed first in ascending order, then string keys in insertion order, then symbols.
Practice
- (Beginner) Given
const stock = { apples: 4, pears: 0, plums: 12 }, useObject.entriesandfilterto list the names of items that are out of stock, andObject.fromEntriesto build an object containing only items in stock.- (Intermediate) Create an object with one own enumerable property, one own non-enumerable property (use
Object.defineProperty, 4.4), one symbol property and one inherited property. Run every tool in the toolbox table on it and check that the output matches the table.- (Interview) "Why do style guides discourage
for...inloops, and what should you use instead?" Hint: think about what happens when someone adds a property toObject.prototype, and about arrays.
Copying, Cloning and Reference Semantics
In 3.1 you saw that a variable holding an object holds a reference, not the object. This one rule explains a whole family of bugs. A function changes an object it was given and the caller sees the change. Two components share the same array and break each other. A "copy" turns out to share its nested data with the original. To avoid these bugs you need to know which operations copy and how deep they go.
Assignment copies the reference. After const b = a, both names refer to the same object. Changing it through one name is visible through the other. Equality operators compare identity, not contents.
const a = { x: 1 };
const b = a; // b refers to the same object
b.x = 99;
console.log(a.x); // 99
console.log({ x: 1 } === { x: 1 }); // false: two different objects with equal contents
console.log(a === b); // true: same object
Passing objects to functions. JavaScript passes every argument by value, but for objects the value is a reference. This is sometimes called call by sharing. A function can mutate the caller's object, but it cannot make the caller's variable point to a different object.
function rename(person) {
person.name = "Grace"; // mutates the shared object: the caller sees this
person = { name: "Linus" }; // rebinds the local parameter only: the caller does not see this
}
const p = { name: "Ada" };
rename(p);
console.log(p.name); // "Grace"
Shallow copies. A shallow copy creates a new top-level object and copies each property value into it. Primitive values are duplicated. Nested objects are not: the copy gets the same references.
const original = { name: "Ada", tags: ["math"], meta: { visits: 1 } };
const viaSpread = { ...original }; // shallow copy (3.2)
const viaAssign = Object.assign({}, original); // shallow copy, older API
viaSpread.name = "Grace"; // top level: independent
viaSpread.tags.push("poetry"); // nested: shared with original!
console.log(original.name); // "Ada"
console.log(original.tags); // ["math", "poetry"]
original ---> { name: "Ada", tags: ref --+--> ["math", "poetry"] <- shared
|
viaSpread --> { name: "Grace", tags: ref --+
Spread and Object.assign differ in one detail. Object.assign(target, ...) assigns to the target, so setters on the target run. Spread defines new properties on a fresh object, so no setters run. Both read getters on the source and copy the resulting value, not the getter itself (see Property Descriptors and defineProperty to copy accessors).
Deep copies. A deep copy recursively copies nested objects so that nothing is shared. The modern built-in is structuredClone (available in browsers and Node.js 17+). It uses the same algorithm the browser uses to send data to Web Workers.
const state = {
user: { name: "Ada" },
created: new Date(2024, 0, 1),
tags: new Set(["a", "b"]),
};
state.self = state; // a circular reference
const copy = structuredClone(state);
copy.user.name = "Grace";
console.log(state.user.name); // "Ada": nested object was copied
console.log(copy.created instanceof Date); // true: Dates survive
console.log(copy.tags.has("a")); // true: Sets survive
console.log(copy.self === copy); // true: cycles are preserved
// structuredClone({ fn() {} }); // DataCloneError: functions cannot be cloned
The older trick JSON.parse(JSON.stringify(obj)) also produces a deep copy, but it loses anything JSON cannot represent (see JSON Serialization and Parsing).
| Technique | Depth | Dates, Map, Set | Functions | Cycles | Prototype / class |
|---|---|---|---|---|---|
b = a | None (same object) | ||||
{ ...a }, Object.assign({}, a) | Shallow | Shared reference | Copied as references | Shared | Lost (result is a plain object) |
structuredClone(a) | Deep | Cloned | Throws DataCloneError | Preserved | Lost (result is a plain object) |
JSON.parse(JSON.stringify(a)) | Deep | Dates become strings, Map/Set become {} | Dropped | Throws TypeError | Lost |
| Custom recursive function | Deep | Whatever you implement | Whatever you implement | Only if you track visited objects | Can be preserved |
Comparing contents. Because === compares identity, comparing contents requires walking both structures. For simple plain data, a recursive comparison is short:
function deepEqual(x, y) {
if (Object.is(x, y)) return true; // same reference or equal primitives
if (typeof x !== "object" || typeof y !== "object" || x === null || y === null) return false;
const kx = Object.keys(x), ky = Object.keys(y);
if (kx.length !== ky.length) return false;
return kx.every((k) => Object.hasOwn(y, k) && deepEqual(x[k], y[k]));
}
console.log(deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] })); // true
Libraries such as Lodash (isEqual) also handle Dates, Maps, Sets and cycles.
Key Takeaways
- Assignment and argument passing copy references, not objects.
===compares identity. - A function can mutate an object it receives, but reassigning the parameter does not affect the caller.
- Spread and
Object.assignmake shallow copies: nested objects are still shared. structuredClonemakes deep copies of data, including Dates, Maps, Sets and cycles. It throws on functions and drops prototypes.JSON.parse(JSON.stringify(x))is a lossy deep copy. Use it only for JSON-safe data.
Practice
- (Beginner) Predict the output, then verify:
const a = { list: [1] }; const b = { ...a }; b.list = [2]; const c = { ...a }; c.list.push(3); console.log(a.list);. Explain why only one of the two changes affectsa.- (Intermediate) Write
deepClone(value)for plain objects, arrays, Dates and primitives. Then add support for circular references using aMapfrom original objects to their clones. Compare your output withstructuredCloneon the same inputs.- (Interview) "A colleague's React component does not re-render after
state.items.push(item); setState(state). Explain why, and show the fix." Hint: consider how the framework decides that the state changed, and what===returns for the old and new state.
Freezing, Sealing and Immutability
Shared references are efficient, but they mean any code holding a reference can change the object. When an object holds configuration, constants or application state that should never change, you want the language to enforce that. JavaScript offers three levels of protection, each stricter than the previous one: preventing extensions, sealing, and freezing.
The three levels.
| Operation | Add properties | Delete properties | Change values | Reconfigure properties | Test with |
|---|---|---|---|---|---|
| (normal object) | Yes | Yes | Yes | Yes | |
Object.preventExtensions(o) | No | Yes | Yes | Yes | Object.isExtensible(o) |
Object.seal(o) | No | No | Yes | No | Object.isSealed(o) |
Object.freeze(o) | No | No | No | No | Object.isFrozen(o) |
All three change the object in place and return it. None of them can be undone.
What happens when code violates the rules. In strict mode (3.4), which includes ES modules and class bodies, a forbidden write throws a TypeError. In sloppy mode the write is silently ignored, which is much harder to debug.
"use strict";
const config = Object.freeze({
apiUrl: "https://api.example.com",
retries: 3,
headers: { Accept: "application/json" },
});
try {
config.retries = 10; // change a value
} catch (e) {
console.log(e.message); // "Cannot assign to read only property 'retries' of object ..."
}
try {
config.timeout = 5000; // add a property
} catch (e) {
console.log(e.message); // "Cannot add property timeout, object is not extensible"
}
config.headers.Accept = "text/html"; // allowed: freeze is shallow!
console.log(config.headers.Accept); // "text/html"
Freezing is shallow. Object.freeze protects the object's own properties. If a property holds another object, that nested object is still mutable. To freeze a whole tree, freeze recursively:
function deepFreeze(obj) {
for (const key of Reflect.ownKeys(obj)) { // include symbol and non-enumerable keys
const value = obj[key];
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
deepFreeze(value); // freeze children first
}
}
return Object.freeze(obj);
}
const settings = deepFreeze({ ui: { theme: "dark" } });
console.log(Object.isFrozen(settings.ui)); // true
The !Object.isFrozen(value) check also stops infinite recursion on circular structures.
Freezing arrays. Arrays are objects, so they can be frozen too. Mutating methods such as push then throw a TypeError even in sloppy mode, because they are specified to throw when a write fails.
const DAYS = Object.freeze(["Mon", "Tue", "Wed"]);
// DAYS.push("Thu"); // TypeError: Cannot add property 3, object is not extensible
const more = [...DAYS, "Thu"]; // non-mutating alternatives still work (4.2)
const versus freezing. These solve different problems and are often combined.
const | Object.freeze | |
|---|---|---|
| Protects | The variable binding | The object's own properties |
| Prevents | x = somethingElse | x.prop = value, adding and deleting properties |
| Depth | Not applicable | Shallow |
Immutability as a style. Instead of locking objects, many codebases simply never mutate shared data. They produce updated copies with spread, map, filter and the non-mutating array methods (4.2). This makes change detection a cheap === check, which frameworks such as React and Redux rely on. Object.freeze is then useful in development to catch accidental mutations early.
const state = { todos: [{ id: 1, done: false }], filter: "all" };
// Mark todo 1 as done without mutating anything
const next = {
...state,
todos: state.todos.map((t) => (t.id === 1 ? { ...t, done: true } : t)),
};
console.log(state.todos[0].done); // false: original untouched
console.log(next.filter === state.filter, next.todos === state.todos); // true false
Key Takeaways
preventExtensionsblocks new properties,sealalso blocks deletions, andfreezealso blocks value changes. None can be reversed.- Violations throw
TypeErrorin strict mode and fail silently in sloppy mode. Object.freezeis shallow. Use a recursivedeepFreezeto protect nested data.constprotects the binding, not the object.- Immutable updates (copy and change) are an alternative to freezing and make change detection a simple
===comparison.
Practice
- (Beginner) Create an object, seal it, and try to add, delete and change a property, once in strict mode and once in sloppy mode. Record which operations throw, which fail silently, and which succeed.
- (Intermediate) Write an immutable
toggleTodo(state, id)and a test that deep-freezesstatebefore calling it. The test should fail with aTypeErrorif your function ever mutates its input.- (Interview) "Does
Object.freezemake an object immutable?" Hint: discuss nested objects, the prototype chain (can inherited properties still change?), and objects whose internal state is not stored in properties, such asMapandDate.
JSON Serialization and Parsing
Objects live in a program's memory. To send them over the network, store them in localStorage or write them to a file, they must be turned into text, and later turned back. Turning an in-memory value into text is called serialisation; the reverse is parsing (or deserialisation). JSON (JavaScript Object Notation) is the standard text format for this on the Web. Its syntax is based on JavaScript object literals, but it is a separate, much stricter language understood by virtually every platform.
JSON syntax versus JavaScript literals.
| Feature | JavaScript literal | JSON |
|---|---|---|
| Keys | Bare identifiers, quoted strings, computed | Double-quoted strings only |
| Strings | 'single', "double", `template` | "double" only |
| Trailing commas | Allowed | Not allowed |
| Comments | Allowed | Not allowed |
| Values | Anything | Strings, numbers, true, false, null, arrays, objects |
undefined, NaN, Infinity, functions, dates | Allowed | Not representable |
{
"id": 42,
"name": "Ada",
"active": true,
"tags": ["admin", "editor"],
"manager": null
}
JSON.stringify(value, replacer, space). Converts a value to a JSON string. The optional space argument pretty-prints with that many spaces of indentation.
const user = { id: 42, name: "Ada", tags: ["admin"] };
console.log(JSON.stringify(user)); // '{"id":42,"name":"Ada","tags":["admin"]}'
console.log(JSON.stringify(user, null, 2)); // multi-line, indented by 2 spaces
Values that JSON cannot represent are converted or dropped according to fixed rules. These rules are a common source of silent data loss:
| JavaScript value | In an object property | In an array | At the top level |
|---|---|---|---|
undefined, function, symbol | Property omitted | null | Returns undefined |
NaN, Infinity, -Infinity | null | null | "null" |
Date | ISO string (via its toJSON method) | ISO string | ISO string |
Map, Set | {} (no enumerable own properties) | {} | "{}" |
BigInt | Throws TypeError | Throws | Throws |
| Circular reference | Throws TypeError | Throws | |
| Class instance | Own enumerable properties only. Prototype and getters are lost. |
const tricky = {
a: undefined,
b: () => 1,
c: NaN,
d: new Date(Date.UTC(2024, 0, 1)),
e: new Map([["k", 1]]),
f: [undefined, Symbol("s")],
};
console.log(JSON.stringify(tricky));
// '{"c":null,"d":"2024-01-01T00:00:00.000Z","e":{},"f":[null,null]}'
Customising serialisation. There are two hooks:
- A
toJSON()method on an object.JSON.stringifycalls it and serialises its return value instead. This is howDateproduces an ISO string. - A replacer: either an array of keys to keep (an allowlist), or a function
(key, value) => newValuecalled for every property. Returningundefinedfrom the function omits the property.
class Money {
constructor(cents, currency) { this.cents = cents; this.currency = currency; }
toJSON() { return `${(this.cents / 100).toFixed(2)} ${this.currency}`; } // custom representation
}
const order = { id: 7, total: new Money(1999, "EUR"), password: "s3cret", items: new Set(["pen"]) };
console.log(JSON.stringify(order, ["id", "total"]));
// '{"id":7,"total":"19.99 EUR"}': only allowlisted keys
console.log(JSON.stringify(order, (key, value) => {
if (key === "password") return undefined; // drop sensitive data
if (value instanceof Set) return [...value]; // make Sets serialisable
return value;
}));
// '{"id":7,"total":"19.99 EUR","items":["pen"]}'
JSON.parse(text, reviver). Converts a JSON string back into values. The result is always made of plain objects, arrays and primitives: no Dates, no class instances. Malformed input throws a SyntaxError, so wrap parsing of untrusted text in try...catch (3.4). The optional reviver function is called for every key, from the innermost values outwards, and can transform values as they are parsed.
const text = '{"title":"Launch","when":"2024-06-01T09:00:00.000Z"}';
const isoDate = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
const event = JSON.parse(text, (key, value) =>
typeof value === "string" && isoDate.test(value) ? new Date(value) : value // revive dates
);
console.log(event.when instanceof Date); // true
function safeParse(text, fallback = null) {
try {
return JSON.parse(text);
} catch { // SyntaxError on invalid JSON
return fallback;
}
}
console.log(safeParse("{'bad': json}")); // null: single quotes are not valid JSON
Where JSON appears in web development. fetch responses (await response.json(), Chapter 5), request bodies (body: JSON.stringify(data) with a Content-Type: application/json header), localStorage (which stores only strings), configuration files such as package.json, and messages between windows or workers.
Key Takeaways
- JSON is a strict text format: double-quoted keys and strings, no comments, no trailing commas, and only strings, numbers, booleans,
null, arrays and objects. JSON.stringifyomitsundefined, functions and symbols in objects, turnsNaNandInfinityintonull, Dates into strings and Maps or Sets into{}. It throws onBigIntand cycles.- Use
toJSONmethods and replacer functions to control output, and an allowlist replacer to avoid leaking fields. JSON.parsereturns plain data only. Use a reviver to rebuild Dates or class instances, and catchSyntaxErrorfor untrusted input.
Practice
- (Beginner) Save a shopping-cart object to
localStoragewithJSON.stringifyand read it back withJSON.parse. Add aDatefield and observe what type it has after the round trip.- (Intermediate) Write
serialize(value)anddeserialize(text)that round-tripMap,Set,DateandBigIntby encoding them as tagged objects such as{ "$type": "Map", "value": [...] }in a replacer, and decoding them in a reviver.- (Interview) "Why does
JSON.parse(JSON.stringify(obj))sometimes fail as a deep-clone technique, and what would you use instead?" Hint: list the value types it changes or drops, the error it raises for some structures, and whatstructuredClonedoes differently.
<a id="42-arrays-and-collections"></a>
4.2 Arrays and Collections
This subchapter covers arrays, JavaScript's ordered lists, along with the methods that transform and query them, and the specialised collections (Map, Set, their weak variants and typed arrays) that are better suited to some jobs.
Array Creation and Indexing
Objects store values under names. Often, though, the data is a sequence: search results, rows of a table, points on a chart, messages in a chat. Order matters, and items are identified by position rather than by name. An array is an ordered list of values, and it is the most frequently used data structure in JavaScript.
Under the hood an array is an object whose keys are the indices "0", "1", "2" and so on, plus a special length property that the engine keeps in sync. Engines store arrays far more efficiently than ordinary objects, but this object model explains several surprising behaviours covered below.
const fruits = ["apple", "banana", "cherry"];
index: 0 1 2
+---------+----------+----------+
| "apple" | "banana" | "cherry" | length: 3
+---------+----------+----------+
at(): -3 -2 -1
Ways to create an array.
const empty = []; // literal: the usual way
const mixed = [1, "two", { three: 3 }]; // elements can have different types
const ofItems = Array.of(7); // [7]: an array containing 7
const sized = new Array(7); // [ <7 empty items> ]: length 7, but no elements!
const pair = new Array(7, 8); // [7, 8]: with two or more arguments it lists elements
const range = Array.from({ length: 5 }, (_, i) => i * 10); // [0, 10, 20, 30, 40]
const chars = Array.from("hey"); // ["h", "e", "y"]: from any iterable
const filled = new Array(3).fill(0); // [0, 0, 0]
The single-number form new Array(n) is a trap: it creates an array of length n with no elements at all (holes), not an array containing n. Prefer literals, Array.of and Array.from.
Reading and writing by index. Indices start at 0. Reading past the end returns undefined rather than throwing. at(i) (ES2022) accepts negative indices that count from the end.
const fruits = ["apple", "banana", "cherry"];
console.log(fruits[0]); // "apple"
console.log(fruits[fruits.length - 1]); // "cherry": the classic way to read the last element
console.log(fruits.at(-1)); // "cherry": the modern way
console.log(fruits[10]); // undefined: no error
console.log(fruits[-1]); // undefined: looks for a property named "-1"
fruits[1] = "blueberry"; // replace an element
console.log(Array.isArray(fruits)); // true: the reliable array test (typeof gives "object")
length is writable. length is always one more than the highest index. Assigning to an index beyond the end extends the array and leaves holes. Assigning a smaller length truncates the array.
const nums = [1, 2, 3];
nums[5] = 6;
console.log(nums); // [1, 2, 3, <2 empty items>, 6]
console.log(nums.length); // 6
nums.length = 2; // truncate: elements 2 and above are deleted
console.log(nums); // [1, 2]
nums.length = 0; // a quick way to empty an array in place
Sparse arrays. An array with holes is called sparse. A hole is not the same as an element holding undefined: the index key does not exist at all. Older methods such as forEach, map and filter skip holes, while for...of, spread and Array.from treat them as undefined. Sparse arrays are also slower, so avoid creating them.
const sparse = [1, , 3]; // a hole at index 1
console.log(1 in sparse); // false: no element there
console.log([1, undefined, 3].hasOwnProperty(1)); // true: an element whose value is undefined
console.log(new Array(3).map((_, i) => i)); // [ <3 empty items> ]: map skips holes
console.log(Array.from({ length: 3 }, (_, i) => i)); // [0, 1, 2]: no holes
The shared-reference trap with fill. fill(value) puts the same value into every slot. When the value is an object, every slot holds a reference to one shared object.
const grid = new Array(3).fill([]); // three references to ONE array
grid[0].push("x");
console.log(grid); // [["x"], ["x"], ["x"]]
const safeGrid = Array.from({ length: 3 }, () => []); // a new array for each row
safeGrid[0].push("x");
console.log(safeGrid); // [["x"], [], []]
Key Takeaways
- An array is an object with index keys and an automatically maintained
length.typeofreturns"object", so useArray.isArray. - Create arrays with literals,
Array.oforArray.from({ length }, fn). Avoidnew Array(n), which creates holes. - Reading outside the bounds returns
undefined. Useat(-1)for the last element. - Writing past the end creates holes, and assigning to
lengthtruncates. Avoid sparse arrays. fill(obj)shares one object across all slots. UseArray.fromwith a factory function instead.
Practice
- (Beginner) Create an array of the first 10 square numbers using
Array.from, then print the first, the last (withat) and the middle element.- (Intermediate) Write
matrix(rows, cols, init)that returns a 2D array where each cell isinit(r, c). Verify that changing one cell does not affect any other cell, and explain why a version built withfillwould fail.- (Interview) "What is the difference between
[ , ],[undefined]andnew Array(1)? What are their lengths and how doforEachandfor...oftreat them?" Hint: check with theinoperator whether index 0 exists in each case.
Mutating vs Non-Mutating Methods
Array methods fall into two families. Mutating methods change the array they are called on. Non-mutating methods leave it untouched and return a new array or value. Mixing up the two families causes two classic bugs. One is changing data that other code still relies on, such as a framework's state or a function's input. The other is calling a non-mutating method and throwing away its result, believing the array was updated. Whenever you call an array method, you should know which family it belongs to.
The two families.
| Purpose | Mutating (changes the array) | Non-mutating (returns new data) |
|---|---|---|
| Add at end / start | push(x), unshift(x) (return the new length) | [...arr, x], [x, ...arr], concat(x) |
| Remove at end / start | pop(), shift() (return the removed element) | slice(0, -1), slice(1) |
| Insert, remove or replace in the middle | splice(start, deleteCount, ...items) | toSpliced(start, deleteCount, ...items) (ES2023) |
| Replace one element | arr[i] = x | with(i, x) (ES2023) |
| Sort | sort(cmp) | toSorted(cmp) (ES2023) |
| Reverse | reverse() | toReversed() (ES2023) |
| Fill / copy within | fill(v, start, end), copyWithin(...) | Array.from / map |
| Extract a range | slice(start, end) | |
| Transform / select | map, filter, flat, flatMap |
sort and reverse return the array itself (the same, mutated array), which hides the fact that they mutate.
splice versus slice. The names differ by one letter, but the methods behave very differently.
const letters = ["a", "b", "c", "d", "e"];
const part = letters.slice(1, 3); // copy indices 1 and 2 (end is exclusive)
console.log(part, letters); // ["b", "c"] ["a", "b", "c", "d", "e"]: original intact
const removed = letters.splice(1, 2, "X", "Y", "Z"); // at index 1: remove 2, insert 3
console.log(removed); // ["b", "c"]: what was removed
console.log(letters); // ["a", "X", "Y", "Z", "d", "e"]: original changed
const copy = letters.slice(); // slice() with no arguments: shallow copy
The ES2023 "change by copy" methods. toSorted, toReversed, toSpliced and with are non-mutating twins of the classic mutating methods. They make immutable updates read naturally:
const scores = [42, 7, 19];
const ranked = scores.toSorted((a, b) => b - a); // [42, 19, 7]
const fixed = scores.with(1, 8); // [42, 8, 19]
const noMid = scores.toSpliced(1, 1); // [42, 19]
console.log(scores); // [42, 7, 19]: never changed
// Before ES2023, the equivalent required copying first:
const rankedOld = [...scores].sort((a, b) => b - a);
A bug from mutation. A function that sorts its input changes the caller's data:
function topThree(items) {
return items.sort((a, b) => b.score - a.score).slice(0, 3); // sort mutates items!
}
const players = [{ n: "A", score: 1 }, { n: "B", score: 3 }, { n: "C", score: 2 }];
topThree(players);
console.log(players.map((p) => p.n)); // ["B", "C", "A"]: the caller's order was destroyed
function topThreeSafe(items) {
return items.toSorted((a, b) => b.score - a.score).slice(0, 3); // input untouched
}
Choosing a family. Mutation is fine, and slightly faster, for arrays that a single function creates and owns, such as a local result being built with push. Prefer non-mutating methods for data that is shared, passed in from outside, or held in framework state.
Key Takeaways
- Mutating methods:
push,pop,shift,unshift,splice,sort,reverse,fill,copyWithin, and index assignment. - Non-mutating methods:
slice,concat,map,filter,flat,flatMap, and the ES2023toSorted,toReversed,toSplicedandwith. slicecopies a range.spliceremoves and inserts in place and returns the removed elements.sortandreversemutate and return the same array. UsetoSortedandtoReversedfor copies.- Never mutate arrays you do not own (function parameters, shared state).
Practice
- (Beginner) Starting from
const q = [1, 2, 3], use mutating methods to implement a queue (add at the end, remove from the front) and a stack (add and remove at the end). Print the return value of each call.- (Intermediate) Implement
insertAt(arr, index, item),removeAt(arr, index)andmove(arr, from, to)without mutatingarr, first using onlysliceand spread, then usingtoSpliced. Freeze the input in your tests to prove it is never modified.- (Interview) "What does
[3, 1, 2].sort().reverse()return, and how many arrays are created along the way?" Hint: check whatsortandreversereturn, and compare it with whattoSorted().toReversed()would do.
Iteration Methods: map, filter and reduce
Most work on arrays follows a handful of shapes: do something with each item, transform each item, keep the items that match, combine all items into one value, or ask whether any or all items satisfy a condition. You could write each of these as a for loop (3.2), but then every loop looks the same and the reader has to decode the body to find out which shape it is. Array iteration methods name the shape. They are higher-order functions (3.3): you pass a callback, and the method takes care of the looping.
The callback signature. Every iteration method calls your callback with three arguments: (element, index, array). Most callbacks use only the first.
The core methods.
| Method | Callback returns | Method returns | Use it to |
|---|---|---|---|
forEach(fn) | Ignored | undefined | Run a side effect for each element |
map(fn) | New value | New array of the same length | Transform each element |
filter(fn) | Truthy or falsy | New array with matching elements | Select elements |
reduce(fn, init) | New accumulator | Final accumulator | Combine elements into one value |
some(fn) | Truthy or falsy | true if any element matches (stops early) | "Is there at least one...?" |
every(fn) | Truthy or falsy | true if all elements match (stops early) | "Are all...?" |
const orders = [
{ id: 1, customer: "Ada", total: 120, paid: true },
{ id: 2, customer: "Linus", total: 80, paid: false },
{ id: 3, customer: "Ada", total: 45, paid: true },
];
const totals = orders.map((o) => o.total); // [120, 80, 45]
const unpaid = orders.filter((o) => !o.paid); // [{ id: 2, ... }]
const revenue = orders.reduce((sum, o) => sum + o.total, 0); // 245
const anyUnpaid = orders.some((o) => !o.paid); // true
const allLarge = orders.every((o) => o.total > 50); // false
orders.forEach((o, i) => console.log(`#${i + 1}: order ${o.id}`));
Chaining into a pipeline. Because map and filter return arrays, calls can be chained. Each step reads as one sentence of the algorithm.
orders --filter(paid)--> [order 1, order 3] --map(total)--> [120, 45] --reduce(+)--> 165
const paidRevenue = orders
.filter((o) => o.paid) // keep paid orders
.map((o) => o.total) // keep only their totals
.reduce((sum, t) => sum + t, 0); // add them up
console.log(paidRevenue); // 165
How reduce works. reduce carries an accumulator through the array. The callback receives the current accumulator and the current element, and returns the next accumulator. The second argument of reduce is the starting accumulator.
[120, 80, 45].reduce((sum, t) => sum + t, 0)
step sum (in) t returns
1 0 120 120
2 120 80 200
3 200 45 245 <- result
Always pass the initial value. Without it, reduce uses the first element as the accumulator and starts at the second. That breaks when the accumulator should have a different type than the elements, and calling it on an empty array throws a TypeError.
// [].reduce((a, b) => a + b); // TypeError: Reduce of empty array with no initial value
console.log([].reduce((a, b) => a + b, 0)); // 0
// Grouping: the accumulator is an object, not a number
const byCustomer = orders.reduce((groups, o) => {
(groups[o.customer] ??= []).push(o); // create the bucket on first use (3.2)
return groups; // always return the accumulator!
}, {});
console.log(Object.keys(byCustomer)); // ["Ada", "Linus"]
// ES2024 has a built-in for this exact task
const grouped = Object.groupBy(orders, (o) => o.customer);
Pitfalls.
// 1. map passes (element, index, array): functions with optional parameters misbehave
console.log(["1", "2", "3"].map(parseInt)); // [1, NaN, NaN]: parseInt("2", 1), parseInt("3", 2)
console.log(["1", "2", "3"].map((s) => parseInt(s, 10))); // [1, 2, 3]
// 2. forEach cannot be stopped with break, and return only ends the current callback
[1, 2, 3].forEach((n) => { if (n === 2) return; console.log(n); }); // logs 1 and 3
// 3. Using map only for side effects creates a useless array: use forEach or for...of
// 4. Async callbacks: forEach does not wait for promises (Chapter 5)
Choosing between methods and loops. Iteration methods are clearer for transforming and querying data. A for...of loop is better when you need break, continue, await in sequence, or very hot loops where creating intermediate arrays matters.
Key Takeaways
- Iteration methods call
fn(element, index, array)for each element and name the intent:maptransforms,filterselects,reducecombines,someandeverytest. mapandfilterreturn new arrays and can be chained into readable pipelines.- Always give
reducean initial value, and always return the accumulator from its callback. Object.groupByandMap.groupBy(ES2024) replace hand-written grouping reducers.- Watch for callbacks with extra optional parameters (
parseInt) and forforEachnot supportingbreakorawait.
Practice
- (Beginner) Given an array of products with
name,priceandinStock, produce the names of in-stock products costing less than 50, sorted alphabetically, using one chained expression.- (Intermediate) Implement your own
myMap,myFilterandmyReduceas plain functions that take an array and a callback. Match the built-in behaviour for the callback arguments, the missing initial value case (including theTypeErrorfor empty arrays), and holes.- (Interview) "Can every array method be implemented with
reduce? Showmapandfilterin terms ofreduce, and explain when you would not usereduce." Hint: the accumulator can be an array. Think about readability and about operations that need to stop early.
Searching, Sorting and Flattening
Beyond transforming data, applications constantly ask questions of arrays: "is this tag already selected?", "which user has this ID?", "show the products ordered by price", "turn these nested lists into one list". JavaScript provides specialised methods for each of these. They are clearer and often faster than a general-purpose filter or reduce, and some of them have surprising default behaviour that you need to know.
Searching.
| Method | Returns | Comparison | Note |
|---|---|---|---|
includes(value) | true / false | SameValueZero (NaN matches NaN) | Best for "is it present?" |
indexOf(value) / lastIndexOf(value) | Index or -1 | Strict equality === | Cannot find NaN |
find(fn) / findLast(fn) | First / last matching element, or undefined | Your predicate | findLast is ES2023 |
findIndex(fn) / findLastIndex(fn) | Index of the match, or -1 | Your predicate |
const users = [
{ id: 1, name: "Ada", role: "admin" },
{ id: 2, name: "Linus", role: "user" },
{ id: 3, name: "Grace", role: "admin" },
];
console.log(users.find((u) => u.id === 2)?.name); // "Linus"
console.log(users.findLast((u) => u.role === "admin").name); // "Grace": searches from the end
console.log(users.findIndex((u) => u.name === "Bob")); // -1: not found
console.log(["a", "b"].includes("b")); // true
console.log([NaN].indexOf(NaN)); // -1: NaN !== NaN
console.log([NaN].includes(NaN)); // true
const target = { id: 1 };
console.log([{ id: 1 }].includes(target)); // false: objects are compared by reference
To search for an object by its contents, use find or some with a predicate. If you repeatedly look up items by key in a large array, build a Map once (see Map, Set, WeakMap and WeakSet) instead of scanning with find each time.
Sorting: the default comparison is textual. sort() without arguments converts elements to strings and compares them by UTF-16 code units. That is correct for simple ASCII strings and wrong for numbers.
console.log([10, 9, 1, 100].sort()); // [1, 10, 100, 9]: compared as "1" < "10" < "100" < "9"
console.log([10, 9, 1, 100].sort((a, b) => a - b)); // [1, 9, 10, 100]
The comparator contract. A comparator (a, b) returns a negative number if a should come first, a positive number if b should come first, and 0 if their order does not matter. Since ES2019 sorting is stable: elements that compare equal keep their original relative order. Stability is what makes multi-key sorting work.
const people = [
{ name: "Émile", age: 30 },
{ name: "ada", age: 25 },
{ name: "Zoe", age: 30 },
];
// Numbers: subtract
people.toSorted((a, b) => a.age - b.age);
// Strings: localeCompare handles case and accents correctly for a language
people.toSorted((a, b) => a.name.localeCompare(b.name, "en", { sensitivity: "base" }));
// ada, Émile, Zoe (plain < comparison would give "Zoe", "ada", "Émile")
// Several keys: age descending, then name ascending
people.toSorted((a, b) => b.age - a.age || a.name.localeCompare(b.name));
// || falls through to the next key only when the first comparison returns 0
// Many comparisons on large arrays: reuse a collator, it is faster than localeCompare
const collator = new Intl.Collator("de", { numeric: true });
["file10", "file2", "file1"].sort(collator.compare); // ["file1", "file2", "file10"]
Remember that sort mutates (see Mutating vs Non-Mutating Methods). Use toSorted when the array is shared.
Flattening. Nested arrays appear when each item produces several results: the tags of each post, the rows of each page. flat(depth) concatenates sub-arrays into the parent, one level deep by default. flatMap(fn) maps and then flattens one level, which also lets a callback return an empty array to drop an element.
const nested = [1, [2, 3], [4, [5, [6]]]];
console.log(nested.flat()); // [1, 2, 3, 4, [5, [6]]]
console.log(nested.flat(Infinity)); // [1, 2, 3, 4, 5, 6]: flatten every level
const posts = [
{ title: "A", tags: ["js", "web"] },
{ title: "B", tags: ["css"] },
];
const allTags = posts.flatMap((p) => p.tags); // ["js", "web", "css"]
// flatMap as map + filter in one pass: return [] to skip, [x] to keep
const parsed = ["1", "x", "3"].flatMap((s) => {
const n = Number(s);
return Number.isNaN(n) ? [] : [n];
});
console.log(parsed); // [1, 3]
Key Takeaways
- Use
includesfor presence (it findsNaN),findandfindIndexfor predicates, andfindLastto search from the end. indexOfandincludescompare objects by reference. Use a predicate to match by content.- Default
sort()compares strings, so it misorders numbers. Always pass a comparator. - Comparators return negative, zero or positive. Sorting is stable, so
||chains several keys. - Use
localeCompareorIntl.Collatorfor human-readable text, andflatorflatMapto collapse nested arrays.
Practice
- (Beginner) Sort
[5, 25, 100, 1]ascending and descending with comparators, and sort["banana", "Apple", "cherry"]case-insensitively.- (Intermediate) Given a list of employees with
department,salaryandname, produce a list sorted by department (A to Z), then salary (highest first), then name. Then writebinarySearch(sortedNumbers, target)that returns the index or-1, and compare its speed withindexOfon an array of one million numbers.- (Interview) "Why does
[1, 2, 10].sort()return[1, 10, 2], and why must a comparator return a number instead of a boolean such asa > b?" Hint: read what the default comparison converts elements to, and consider what the sort algorithm learns when the comparator returnsfalse, which converts to0.
Array-Like Objects and Conversion
Some values look like arrays but are not. The DOM method document.querySelectorAll returns a NodeList, the old arguments object inside functions has indexed elements and a length, and strings can be indexed by position. You can read their elements by index, yet calling map on many of them fails. Knowing the difference between an array, an array-like and an iterable, and how to convert between them, is a daily need in browser code.
Three different capabilities.
| Concept | Definition | Examples |
|---|---|---|
| Array | A real Array, with all array methods | [1, 2], Array.from(x) |
| Array-like | Any object with a numeric length and index keys 0..length-1 | arguments, NodeList, HTMLCollection, strings, { 0: "a", length: 1 } |
| Iterable | Any object with a [Symbol.iterator] method, usable with for...of and spread (3.3) | Arrays, strings, Map, Set, NodeList, generators |
These overlap but are not the same. A NodeList is both array-like and iterable. { length: 2 } is array-like but not iterable. A Set is iterable but not array-like (no index access).
iterable only iterable AND array-like array-like only
+---------------+ +-------------------------------+ +----------------------+
| Map, Set, | | Array, string, arguments, | | { 0: "a", length: 1 }|
| generators | | NodeList, HTMLCollection, | | (hand-made objects) |
| | | typed arrays | | |
+---------------+ +-------------------------------+ +----------------------+
for...of, spread everything works indices + length only:
work; no [i] access no for...of, no spread
Converting to a real array.
| Technique | Works on | Notes |
|---|---|---|
Array.from(x) | Iterables and array-likes | Most general. Optional second argument maps each element. |
[...x] | Iterables only | Throws TypeError for non-iterable array-likes |
Array.prototype.slice.call(x) | Array-likes | Legacy (pre-ES2015) idiom using method borrowing (4.4) |
function legacySum() {
// arguments is array-like: it has indices and length but no reduce
return Array.from(arguments).reduce((a, b) => a + b, 0);
}
console.log(legacySum(1, 2, 3)); // 6 (prefer rest parameters, 3.3)
const arrayLike = { 0: "a", 1: "b", length: 2 };
console.log(Array.from(arrayLike)); // ["a", "b"]
// console.log([...arrayLike]); // TypeError: arrayLike is not iterable
console.log(Array.from({ length: 3 })); // [undefined, undefined, undefined]
console.log(Array.from("abc", (c) => c.toUpperCase())); // ["A", "B", "C"]: map while converting
DOM collections. querySelectorAll returns a static NodeList, a snapshot that supports forEach but not map or filter. Properties such as element.children and getElementsByClassName return a live HTMLCollection, which updates automatically when the document changes (Chapter 6). Convert to an array before transforming, and especially before removing elements while iterating over a live collection.
const links = document.querySelectorAll("a"); // static NodeList
const external = Array.from(links)
.filter((a) => a.hostname !== location.hostname)
.map((a) => a.href);
const items = document.getElementsByClassName("item"); // live HTMLCollection
for (const el of [...items]) el.classList.remove("item"); // iterate over a snapshot copy
// Iterating `items` directly would skip elements, because each removal shrinks the live collection
Strings and Unicode. Strings are array-like by UTF-16 code unit, but iterable by code point. Characters outside the Basic Multilingual Plane (many emoji and some CJK characters) take two code units, so the two conversions give different results (3.1).
const s = "a\u{1F600}b"; // "a", one emoji, "b"
console.log(s.length); // 4: the emoji is two UTF-16 code units
console.log(Array.prototype.slice.call(s).length); // 4: array-like view splits the pair
console.log([...s].length); // 3: iteration yields whole code points
console.log(Array.from(s).length); // 3: Array.from prefers the iterator
Calling array methods directly. Many array methods are intentionally generic: they work on any array-like this. You can borrow them without converting (see Method Borrowing and Mixins):
const upper = Array.prototype.map.call("hey", (c) => c.toUpperCase()); // ["H", "E", "Y"]
Key Takeaways
- An array-like has
lengthand index keys. An iterable has[Symbol.iterator]. A real array is both and has every array method. Array.fromconverts both array-likes and iterables, and can map at the same time. Spread works only on iterables.querySelectorAllreturns a staticNodeList.childrenandgetElementsBy*return live collections, so iterate over a copy when modifying the DOM.- Converting a string by iteration (
[...s],Array.from) keeps code points together. Index-based access splits surrogate pairs.
Practice
- (Beginner) On any web page, use
querySelectorAll("h2")andArray.fromto produce an array of heading texts, then print how many headings contain a given word.- (Intermediate) Write
toArray(value)that returns a real array for arrays (a copy), iterables, array-likes and single values (wrapped in an array), and returns[]fornullandundefined. Test it with aSet, a string,arguments,{ length: 2, 0: "x" }and the number5.- (Interview) "Why does
document.getElementsByClassName('x')sometimes skip elements when you remove the class inside aforloop over it, whilequerySelectorAlldoes not?" Hint: compare live and static collections, and trace the indices as the collection changes.
Map, Set, WeakMap and WeakSet
For years, JavaScript developers used plain objects as dictionaries and arrays as sets, because there was nothing else. Both have problems. Object keys are always strings, so you cannot key by an object or keep 1 and "1" apart. Inherited keys such as "toString" or "__proto__" can collide with your data. There is no built-in size, and order rules are quirky. Checking membership in an array means scanning it. ES2015 added dedicated collection types that solve these problems: Map, Set, and their "weak" variants for attaching data to objects without keeping those objects alive.
Map: a true key-value dictionary. Keys can be any value, including objects and functions. Entries are kept in insertion order, and the map knows its size.
const visits = new Map();
const home = { path: "/" };
visits.set(home, 1); // an object as key
visits.set("1", "string key");
visits.set(1, "number key"); // distinct from "1"
console.log(visits.get(home)); // 1
console.log(visits.get(1), visits.get("1")); // "number key" "string key"
console.log(visits.has(home), visits.size); // true 3
visits.delete("1");
for (const [key, value] of visits) console.log(key, value); // insertion order
// Converting between Map and objects
const m = new Map(Object.entries({ a: 1, b: 2 }));
const o = Object.fromEntries(m); // { a: 1, b: 2 }
| Plain object | Map | |
|---|---|---|
| Key types | Strings and symbols | Any value |
| Accidental keys | Inherited from the prototype (toString, __proto__) | None |
| Size | Object.keys(o).length | m.size |
| Iteration order | Integer-like keys first, then insertion order | Insertion order |
| Iteration | Through Object.keys / entries | Directly iterable |
| Frequent add/delete | Not optimised for it | Optimised for it |
| JSON | Native | Must convert (Object.fromEntries or [...m]) |
| Best for | Records with a fixed shape | Dictionaries with dynamic or non-string keys |
Set: a collection of unique values. Adding a value that is already present does nothing. Membership tests with has are fast regardless of size, unlike array.includes, which scans. Equality uses SameValueZero: NaN equals NaN, and objects are compared by reference.
const tags = new Set(["js", "css"]);
tags.add("js"); // no effect: already present
tags.add("html");
console.log(tags.size, tags.has("css")); // 3 true
const unique = [...new Set([3, 1, 3, 2, 1])]; // [3, 1, 2]: the idiomatic de-duplication
// ES2025 set algebra
const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);
console.log(a.union(b)); // Set {1, 2, 3, 4}
console.log(a.intersection(b)); // Set {2, 3}
console.log(a.difference(b)); // Set {1}
console.log(a.isSubsetOf(new Set([1, 2, 3, 4]))); // true
Weak collections. In a normal Map, a key object is kept alive as long as the map exists, even if nothing else references it. When you attach data to objects you do not control (DOM nodes, objects from a library), this causes memory leaks. WeakMap and WeakSet hold their keys weakly: an entry does not prevent the garbage collector from reclaiming its key. When the key object becomes unreachable elsewhere, the entry disappears (see Memory Model and Garbage Collection).
The price is that weak collections cannot be iterated and have no size: their contents depend on when garbage collection runs, so listing them would give unpredictable results. Their keys must be objects (or, since ES2023, non-registered symbols).
// Cache derived data per object without leaking memory
const layoutCache = new WeakMap();
function measure(element) {
if (!layoutCache.has(element)) {
layoutCache.set(element, element.getBoundingClientRect()); // computed once per element
}
return layoutCache.get(element);
}
// When an element is removed from the DOM and dropped by your code, its cache entry can be collected
// Mark objects without modifying them
const processed = new WeakSet();
function processOnce(obj) {
if (processed.has(obj)) return;
processed.add(obj);
// ... work ...
}
Map | WeakMap | Set | WeakSet | |
|---|---|---|---|---|
| Stores | Key-value pairs | Key-value pairs | Values | Values |
| Key / value types | Any | Objects (and non-registered symbols) as keys | Any | Objects (and non-registered symbols) |
| Keeps keys alive | Yes | No | Yes | No |
Iterable, size, clear | Yes | No | Yes | No |
| Typical use | Dictionaries, lookups, counting | Per-object metadata, caches, private data | Unique values, fast membership | Tagging objects |
Key Takeaways
- Use
Mapfor dictionaries whose keys are dynamic, non-string or numerous. Use plain objects for fixed-shape records. Mapkeeps insertion order, accepts any key, hassize, and is directly iterable.Setstores unique values with fasthas.[...new Set(arr)]removes duplicates, and ES2025 addsunion,intersectionanddifference.WeakMapandWeakSethold object keys weakly, so attached data is collected with its key. In exchange they are not iterable and have nosize.- Neither
MapnorSetserialises to JSON automatically. Convert withObject.fromEntriesor spread.
Practice
- (Beginner) Count word frequencies in a paragraph with a
Map, then print the five most frequent words. Repeat with a plain object and note what happens with the word"constructor".- (Intermediate) Implement an LRU (least recently used) cache class with a capacity,
get(key)andset(key, value), using the fact that aMapiterates in insertion order. Moving a key to the "most recent" end can be done by deleting and re-inserting it.- (Interview) "Why can't you iterate a
WeakMapor read its size? When would you choose aWeakMapover aMap?" Hint: think about what the garbage collector is allowed to do with a key at any moment, and give an example involving DOM elements.
Typed Arrays and ArrayBuffer
Regular arrays can hold any mix of values, which makes them flexible but unsuitable for raw binary data. Images, audio samples, files read from disk, WebGL vertex buffers, WebAssembly memory and network protocols all deal in bytes and fixed-size numbers: "a 32-bit float, then two unsigned 16-bit integers". For this JavaScript provides typed arrays: fixed-length, densely packed arrays of a single numeric type, stored in a contiguous block of memory.
Buffers and views. The design separates storage from interpretation:
- An
ArrayBufferis a fixed-length block of raw bytes. You cannot read or write it directly. - A view gives the bytes a meaning. A typed array (such as
Uint8ArrayorFloat32Array) treats the bytes as a sequence of numbers of one type. ADataViewreads and writes individual values of any type at any byte offset, with explicit byte order.
Several views can share one buffer, so the same bytes can be read in different ways at the same time.
const buf = new ArrayBuffer(8); 8 bytes of raw memory
byte offset: 0 1 2 3 4 5 6 7
+----+----+----+----+----+----+----+----+
ArrayBuffer | 00 | 00 | 00 | 00 | 00 | 00 | 00 | 00 |
+----+----+----+----+----+----+----+----+
Uint8Array | [0]| [1]| [2]| [3]| [4]| [5]| [6]| [7]| 8 elements, 1 byte each
Uint16Array | [0] | [1] | [2] | [3] | 4 elements, 2 bytes each
Float32Array | [0] | [1] | 2 elements, 4 bytes each
DataView | any type at any offset: getUint16(1), setFloat32(4, x, true) ...
The typed array family.
| Type | Bytes per element | Range / meaning | Typical use |
|---|---|---|---|
Int8Array / Uint8Array | 1 | -128..127 / 0..255 | Raw bytes, files, network data |
Uint8ClampedArray | 1 | 0..255, values clamped instead of wrapped | Canvas pixel data (Chapter 7) |
Int16Array / Uint16Array | 2 | -32768..32767 / 0..65535 | Audio samples, indices |
Int32Array / Uint32Array | 4 | 32-bit integers | Counters, hashes |
Float32Array | 4 | Single-precision float | WebGL vertex data, audio (Web Audio API) |
Float64Array | 8 | Same as number | Scientific data |
BigInt64Array / BigUint64Array | 8 | 64-bit integers as bigint | 64-bit IDs, file offsets |
Behaviour differences from regular arrays. The length is fixed, so there is no push, pop or splice. Elements are initialised to 0. Values that do not fit the type are converted: integers wrap around (modulo 2^n), clamped arrays saturate, and floats lose precision.
const bytes = new Uint8Array([10, 255, 256, -1, 3.7]);
console.log(bytes); // Uint8Array [10, 255, 0, 255, 3]: wrapped and truncated
const pixels = new Uint8ClampedArray([300, -5]);
console.log(pixels); // Uint8ClampedArray [255, 0]: clamped to the range
const f = new Float32Array([0.1]);
console.log(f[0]); // 0.10000000149011612: single precision
const doubled = new Float64Array([1, 2, 3]).map((x) => x * 2); // most array methods exist
console.log(Array.isArray(doubled)); // false: it is a typed array, not an Array
Sharing versus copying. subarray(start, end) creates a new view on the same buffer, so writes through it affect the original. slice(start, end) copies the bytes into a new buffer.
const data = new Uint8Array([1, 2, 3, 4]);
const view = data.subarray(1, 3); // shares memory
const copy = data.slice(1, 3); // independent copy
view[0] = 99;
console.log(data[1], copy[0]); // 99 2
Reading a binary format with DataView. Binary formats specify the endianness (byte order) of multi-byte numbers. Typed arrays always use the platform's native order, which is little-endian on almost all hardware. DataView lets you choose the order explicitly, which is essential for file formats and network protocols. The example reads the width and height of a PNG image, which the format stores as big-endian 32-bit integers at byte offsets 16 and 20.
async function pngSize(url) {
const buffer = await (await fetch(url)).arrayBuffer(); // the file as raw bytes (Chapter 5)
const view = new DataView(buffer);
const signature = new Uint8Array(buffer, 0, 8); // a typed-array view on bytes 0..7
if (signature[1] !== 0x50 || signature[2] !== 0x4e || signature[3] !== 0x47) { // "PNG"
throw new Error("Not a PNG file");
}
return {
width: view.getUint32(16, false), // false = big-endian, as the PNG spec requires
height: view.getUint32(20, false),
};
}
Text and binary. TextEncoder converts a string to UTF-8 bytes (a Uint8Array), and TextDecoder converts bytes back to a string. Many APIs accept or return buffers directly: fetch (response.arrayBuffer()), FileReader and Blob.arrayBuffer(), WebSockets, crypto.subtle and WebGL.
const encoded = new TextEncoder().encode("héllo"); // Uint8Array(6): "é" takes 2 bytes in UTF-8
console.log(encoded.length); // 6
console.log(new TextDecoder().decode(encoded)); // "héllo"
Key Takeaways
- An
ArrayBufferis raw, fixed-size memory. Typed arrays andDataVieware views that interpret its bytes. - Typed arrays hold one numeric type, have a fixed length, start filled with zeros, and wrap, clamp or round values that do not fit.
subarrayshares memory with the original.slicecopies.- Use
DataViewfor mixed-type binary formats and explicit endianness. Typed arrays use the platform's native byte order. TextEncoderandTextDecoderconvert between strings and UTF-8 bytes.
Practice
- (Beginner) Create a
Uint8Arrayof length 4, write the values 0, 128, 255 and 256 into it, and explain each stored value. Then view the same buffer as aUint32Arrayand print its single element.- (Intermediate) Write
toHex(buffer)that returns a hexadecimal string such as"89504e47"for anArrayBuffer, and use it withcrypto.subtle.digest("SHA-256", new TextEncoder().encode(text))to compute the SHA-256 hash of a string.- (Interview) "What is endianness, and why does
new Uint16Array(new Uint8Array([1, 0]).buffer)[0]return1on most machines? How would you read a big-endian value portably?" Hint: write out which byte is most significant in each order, and look at the second parameter ofDataView.prototype.getUint16.
<a id="43-object-oriented-programming"></a>
4.3 Object-Oriented Programming
This subchapter covers how the classic ideas of object-oriented programming map onto JavaScript, how this is determined, and the class syntax with its fields, static members, private members, inheritance and accessors.
Encapsulation, Inheritance and Polymorphism in JavaScript
As programs grow, the main difficulty is not writing code but keeping it understandable. Object-oriented programming (OOP) addresses this by bundling data with the operations that act on it, so that each piece of the program is a self-contained "thing" with a clear interface: a shopping cart that knows how to add items and compute its total, a modal dialog that knows how to open and close. Three ideas make this work: encapsulation, inheritance and polymorphism. JavaScript supports all three, but its mechanisms differ from class-based languages such as Java or C#, and knowing those differences prevents a lot of confusion.
Encapsulation means hiding an object's internal state behind a public interface, so other code cannot put it into an invalid state. Think of a vending machine: you press buttons and receive drinks, but you cannot reach into the coin box. In JavaScript you can encapsulate with closures (3.3) or with private class fields (see Static Members and Private Fields).
Inheritance means defining a new kind of object as a specialisation of an existing one, reusing its behaviour. In JavaScript inheritance is prototypal: objects delegate property lookups to another object, their prototype (4.4). The class and extends keywords are a convenient syntax over this mechanism.
Polymorphism means code can work with different kinds of objects through a common interface, and each object responds in its own way. JavaScript is dynamically typed, so polymorphism needs no declared interfaces: any object with the right method works. This is called duck typing (see Abstract Patterns and Duck Typing).
How JavaScript compares with a class-based language.
| Concept | Java | JavaScript |
|---|---|---|
| Blueprint | Classes are compile-time types | class creates a constructor function plus a prototype object at runtime |
| Inheritance | Class hierarchy, copied structure | Chain of live prototype objects that lookups delegate to |
| Encapsulation | private, protected, public | Closures, #private fields. No protected. |
| Interfaces | interface, checked by the compiler | No interfaces. Duck typing at runtime, or TypeScript (Chapter 9) |
| Polymorphism | Overriding plus static types | Overriding plus duck typing |
| Changing structure at runtime | Not possible | Objects and prototypes can be modified at any time |
| Objects without a class | Not possible | Normal: object literals |
One example, two styles. The same encapsulated, polymorphic "shape" model can be written with factory functions and closures, or with classes.
// Style 1: factory functions + closures (encapsulation through scope)
function createCircle(radius) {
return {
name: "circle",
area: () => Math.PI * radius ** 2, // radius is captured, not exposed as a property
};
}
function createSquare(side) {
return { name: "square", area: () => side ** 2 };
}
// Style 2: classes (encapsulation through private fields, inheritance through extends)
class Shape {
describe() {
return `${this.constructor.name} with area ${this.area().toFixed(2)}`; // calls the subclass's area
}
}
class Circle extends Shape {
#radius; // private field
constructor(radius) { super(); this.#radius = radius; }
area() { return Math.PI * this.#radius ** 2; }
}
class Square extends Shape {
#side;
constructor(side) { super(); this.#side = side; }
area() { return this.#side ** 2; }
}
// Polymorphism: this code works with any object that has area(), whatever its origin
const shapes = [new Circle(1), new Square(2), createCircle(2)];
const totalArea = shapes.reduce((sum, s) => sum + s.area(), 0);
console.log(totalArea.toFixed(2)); // "19.71"
console.log(shapes[0].describe()); // "Circle with area 3.14"
Both styles are idiomatic. Classes are better when you create many instances (methods are shared through the prototype rather than recreated per object) and when you want instanceof and inheritance. Factories are simpler for small objects and give real privacy without new syntax.
Composition over inheritance. Deep inheritance hierarchies are brittle: a change in a base class ripples through every subclass. A common guideline in JavaScript, as in other languages, is to prefer composition, building objects that contain or combine behaviours, and to keep inheritance shallow. Mixins (4.4) are one form of composition.
Key Takeaways
- Encapsulation hides state behind an interface, inheritance specialises existing behaviour, and polymorphism lets one piece of code work with many kinds of objects.
- JavaScript inheritance is prototypal: objects delegate to other live objects.
classis syntax over that mechanism. - Encapsulate with closures or
#privatefields. There is noprotected. - Polymorphism relies on duck typing: any object with the expected methods works.
- Prefer composition and shallow hierarchies over deep inheritance trees.
Practice
- (Beginner) Write a
createCounter()factory that returns an object withincrement,decrementandvaluemethods, where the count cannot be modified directly from outside.- (Intermediate) Model three payment methods (card, PayPal, bank transfer), each with a
pay(amount)method that returns a different receipt string. Write acheckout(cart, method)function that works with any of them. Implement it once with classes and once with plain objects, and compare.- (Interview) "Is JavaScript an object-oriented language if it did not have classes until ES2015?" Hint: separate the idea of OOP (objects, encapsulation, polymorphism) from one implementation of it (classes), and describe what JavaScript used instead.
The this Binding Rules
Methods need a way to refer to the object they were called on: cart.total() must add up this cart's items, not some other cart's. JavaScript provides the keyword this for that. Unlike in Java, this in a regular function is not fixed when the function is written. It is decided each time the function is called, by how it is called. This flexibility lets one function be shared by many objects, but it is also why a method passed as a callback can suddenly lose its object. 3.3 introduced arrow functions and call, apply and bind. This topic gives the complete rules.
The four rules for regular functions, from highest to lowest priority:
| Rule | Call form | this is | Example |
|---|---|---|---|
1. new binding | new F() | The newly created object | new Date() |
| 2. Explicit binding | f.call(obj), f.apply(obj), f.bind(obj)() | obj | greet.call(user) |
| 3. Implicit binding | obj.f() | The object before the dot | user.greet() |
| 4. Default binding | f() | undefined in strict mode, globalThis in sloppy mode | greet() |
Arrow functions do not follow these rules at all: they have no this of their own and use the this of the surrounding scope at the place they were defined (lexical this). call, apply, bind and new cannot change it.
How is the function called?
|
+-- Is it an arrow function? ----------------> this of the enclosing scope (lexical)
+-- With new? -------------------------------> the new object
+-- With call / apply, or is it a bound fn? --> the object passed in
+-- As obj.method() or obj[key]()? -----------> obj
+-- Plainly, as fn()? ------------------------> undefined (strict) / globalThis (sloppy)
"use strict";
function whoAmI() { return this?.name ?? String(this); }
const ada = { name: "Ada", whoAmI };
const linus = { name: "Linus" };
console.log(ada.whoAmI()); // "Ada": implicit (rule 3)
console.log(whoAmI.call(linus)); // "Linus": explicit (rule 2)
console.log(whoAmI()); // "undefined": default in strict mode (rule 4)
const bound = whoAmI.bind(linus);
console.log(bound.call(ada)); // "Linus": a bound function ignores later call/apply
ada.bound = bound;
console.log(ada.bound()); // "Linus": explicit binding beats implicit binding
function Person(name) { this.name = name; }
const BoundPerson = Person.bind({ name: "ignored" });
console.log(new BoundPerson("Grace").name); // "Grace": new beats bind (rule 1)
Losing this. Implicit binding depends on the call site literally having the form obj.method(). As soon as the function is taken off the object and called on its own, it falls back to the default binding. This happens in three common situations:
"use strict";
class Timer {
seconds = 0;
tick() { this.seconds++; }
}
const t = new Timer();
// 1. Passing a method as a callback
// setInterval(t.tick, 1000); // TypeError: Cannot read properties of undefined (reading 'seconds')
// 2. Storing the method in a variable or destructuring it
const { tick } = t;
// tick(); // same TypeError: called as a plain function
// 3. Event handlers: the DOM calls the function with this = the element (Chapter 6)
// button.addEventListener("click", t.tick); // this is the button, not t
Three fixes.
// Fix 1: wrap the call in an arrow function, so the call site is t.tick() again
setInterval(() => t.tick(), 1000);
// Fix 2: bind once and pass the bound function
setInterval(t.tick.bind(t), 1000);
// Fix 3: define the method as an arrow-function class field (one copy per instance)
class SafeTimer {
seconds = 0;
tick = () => { this.seconds++; }; // this is captured from the instance when the field is created
}
const st = new SafeTimer();
setInterval(st.tick, 1000); // works
Fix 3 is convenient for event handlers in UI components, but each instance gets its own function instead of sharing one through the prototype, and subclasses cannot call it with super.tick().
this in other places.
| Location | this |
|---|---|
| Top level of an ES module | undefined |
| Top level of a classic script | globalThis (window in browsers) |
| Inside a class body (methods, constructor) | Class bodies are always strict, so the default binding is undefined |
| Class field initialiser | The instance being constructed |
Static method called as C.m() | The class C itself |
| DOM event handler (regular function) | event.currentTarget, the element the listener is attached to |
Callback with a thisArg (arr.map(fn, obj)) | obj |
Key Takeaways
thisin a regular function is decided at call time. In priority order:new, then explicit (call,apply,bind), then implicit (obj.f()), then the default (undefinedin strict mode).- Arrow functions take
thisfrom the scope where they are defined, and nothing can rebind it. - Passing
obj.methodas a value losesobj. Fix it with an arrow wrapper,bind, or an arrow-function class field. - Class bodies and modules are strict, so a lost
thisisundefinedand fails loudly instead of silently using the global object.
Practice
- (Beginner) Predict the output of
const o = { n: 1, f() { return this.n; }, g: () => this?.n }; console.log(o.f(), o.g(), (0, o.f)?.call({ n: 2 }));in an ES module, then run it and explain each value.- (Intermediate) Write a
Counterclass whoseincrementmethod is attached to a button withaddEventListener. Show the bug, then fix it in three different ways, and use the browser's memory tools or a simple instance count to compare how many function objects each fix creates for 1,000 counters.- (Interview) "What is the value of
thisinsidesetTimeout(obj.method, 0), and why?" Hint: the timer calls the function it received without any object in front of it. Consider strict and sloppy mode, and whatsetTimeoutdoes in browsers versus Node.js.
Classes and the class Syntax
Before ES2015, JavaScript had no class keyword. Developers built "classes" by hand from constructor functions and prototype objects (4.4), and every library had its own helpers for it. The class syntax standardised this pattern, made it far more readable, and added features that are hard to emulate, such as private fields. It is important to understand that a class is not a new kind of entity: it is still a function with a prototype object. The syntax simply sets them up correctly for you.
Anatomy of a class.
class Temperature {
unit = "C"; // public instance field: created on each instance
constructor(degrees) { // runs when you call new Temperature(...)
this.degrees = degrees; // another own property of the instance
}
toFahrenheit() { // method: stored once on Temperature.prototype
return this.degrees * 9 / 5 + 32;
}
toString() { // overrides Object.prototype.toString
return `${this.degrees} °${this.unit}`;
}
}
const t = new Temperature(21);
console.log(t.toFahrenheit()); // 69.8
console.log(`${t}`); // "21 °C": template literals call toString
console.log(Object.keys(t)); // ["unit", "degrees"]: fields and constructor props are own
console.log(Object.hasOwn(t, "toFahrenheit")); // false: methods live on the prototype
What the class declaration creates.
Temperature (a function) Temperature.prototype Object.prototype
+------------------------+ +-------------------------+ +----------------+
| the constructor code |-------->| constructor: Temperature|----->| toString, ... |
| prototype: ref --------+ | toFahrenheit() | +----------------+
+------------------------+ | toString() |
+-------------------------+
^
| [[Prototype]]
t +--------------------+
| unit: "C" | own data: one copy per instance
| degrees: 21 |
+--------------------+
console.log(typeof Temperature); // "function"
console.log(Object.getPrototypeOf(t) === Temperature.prototype); // true
Classes versus hand-written constructor functions. The result is similar, but the class syntax adds safety:
| Behaviour | class | function constructor (4.4) |
|---|---|---|
Calling without new | TypeError: Class constructor ... cannot be invoked without 'new' | Runs, with this undefined or global |
| Strict mode | Always, for the whole class body | Only if you opt in |
| Methods enumerable | No (do not show up in for...in) | Yes, if assigned to the prototype |
| Hoisting | Hoisted but in the temporal dead zone (like let, 3.1) | Fully hoisted |
Fields, #private, static blocks | Supported | Not available |
super | Supported | Not available |
Fields versus methods. A field (unit = "C") is created as an own property of each instance, after super() returns (in a subclass) and before the rest of the constructor runs. Field initialisers can use this and earlier fields. A method is defined once on the prototype and shared by all instances. Do not put mutable shared data such as arrays on the prototype. Use fields, so that each instance gets its own copy.
class Cart {
items = []; // each cart gets its own array
createdAt = Date.now(); // initialiser runs per instance
count = () => this.items.length; // arrow-function field: a per-instance function
add(item) { this.items.push(item); return this; } // shared prototype method
}
const c1 = new Cart().add("pen");
const c2 = new Cart();
console.log(c1.items, c2.items); // ["pen"] []: not shared
console.log(c1.add === c2.add, c1.count === c2.count); // true false
Class expressions. Like functions, classes can be expressions, which can be assigned, passed or returned. This is the basis of the mixin pattern in 4.4.
const Point = class {
constructor(x, y) { this.x = x; this.y = y; }
};
function makeEntity(kind) {
return class { kind = kind; }; // a new class created at runtime
}
console.log(new (makeEntity("user"))().kind); // "user"
Key Takeaways
- A class is a constructor function plus a prototype object.
typeofa class is"function". constructorinitialises instances. Methods are stored once onClass.prototypeand shared.- Fields are own properties created per instance. Use them for per-instance state, including arrays and objects.
- Classes must be called with
new, are always strict, have non-enumerable methods, and are in the temporal dead zone until their declaration runs. - Class expressions let you create and return classes dynamically.
Practice
- (Beginner) Write a
Rectangleclass withwidthandheight, methodsarea()andperimeter(), and atoString()that returns"Rectangle 3x4". Check which properties are own and which are inherited.- (Intermediate) Rewrite the following constructor function as a class, keeping its behaviour identical:
function Stack() { this.items = []; } Stack.prototype.push = function (x) { this.items.push(x); return this; }; Stack.prototype.peek = function () { return this.items.at(-1); };. Then list three behavioural differences between the two versions that you can observe at runtime.- (Interview) "Are JavaScript classes just syntactic sugar?" Hint: show which parts can be reproduced with functions and prototypes, and which cannot (private fields, the
newcheck,superin object methods, correctly extending built-ins such asArray).
Static Members and Private Fields
Not every piece of behaviour belongs to an individual instance. A factory method that parses a string into a Temperature, a counter of how many users have been created, a table of constants: these belong to the class as a whole. Conversely, some instance data must not be reachable from outside at all, such as a password hash or an internal cache whose consistency the class must guarantee. Static members and private members cover these two needs.
Static members belong to the class (the constructor function) itself, not to its instances. Built-in examples are Array.from, Object.keys, Number.isNaN and Date.now. Inside a static method, this is the class.
class User {
static #count = 0; // private static field
static ROLES = Object.freeze(["admin", "editor", "viewer"]); // public static constant
static fromJSON(json) { // static factory method
const { name, role } = JSON.parse(json);
return new this(name, role); // this = the class (or a subclass, see extends)
}
static get count() { return User.#count; }
constructor(name, role = "viewer") {
if (!User.ROLES.includes(role)) throw new RangeError(`Unknown role: ${role}`);
this.name = name;
this.role = role;
User.#count++;
}
}
const u = User.fromJSON('{"name":"Ada","role":"admin"}');
console.log(User.count); // 1
console.log(u.ROLES); // undefined: static members are not on instances
Static initialisation blocks (ES2022) run once when the class is defined. They are useful for setup that needs several statements, such as reading configuration or building lookup tables.
class Config {
static values;
static {
const env = globalThis.process?.env ?? {}; // Node.js environment, or {} in browsers
this.values = { debug: env.DEBUG === "1", region: env.REGION ?? "eu" };
}
}
Private members. A name that starts with # is private: it can be accessed only by code written inside the class body. This is enforced by the language. Using it outside the class is a syntax error, so the code does not even run. Fields, methods, getters and setters, both static and instance, can all be private.
class BankAccount {
#balance = 0; // private field: must be declared in the class body
#history = [];
deposit(amount) {
this.#validate(amount);
this.#balance += amount;
this.#history.push({ type: "deposit", amount });
return this;
}
withdraw(amount) {
this.#validate(amount);
if (amount > this.#balance) throw new Error("Insufficient funds");
this.#balance -= amount;
this.#history.push({ type: "withdraw", amount });
return this;
}
get balance() { return this.#balance; } // read-only public view
#validate(amount) { // private method
if (!(amount > 0)) throw new RangeError("Amount must be positive");
}
static isAccount(obj) { return #balance in obj; } // brand check (ES2022)
}
const acc = new BankAccount().deposit(100).withdraw(30);
console.log(acc.balance); // 70
// acc.#balance = 1e6; // SyntaxError: Private field '#balance' must be declared in an enclosing class
// acc.balance = 1e6; // no setter: TypeError in strict code, silently ignored in sloppy mode
console.log(Object.keys(acc)); // []: private fields are invisible to reflection
console.log(BankAccount.isAccount(acc), BankAccount.isAccount({})); // true false
Properties of #private names.
- They are not properties in the usual sense:
Object.keys,Reflect.ownKeys,JSON.stringify, spread andstructuredClonedo not see them. - They are per class, not per object: any code inside the class can access
#balanceof another instance of the same class, which is useful for methods such asequals(other). - They are not inherited for access: a subclass's code cannot read its parent's
#fields. Expose a getter or a protected-by-convention method instead. - Accessing a private field on an object that does not have it throws a
TypeError.#x in objchecks safely. - They do not work through a
Proxy(4.4): inside a method called on a proxy,thisis the proxy, which has no private fields.
Ways to hide data in JavaScript.
| Technique | Privacy | Notes |
|---|---|---|
_name convention | None, a social contract | Still common in older code |
| Closures (factory functions, 3.3) | Real | Methods are created per instance |
WeakMap keyed by instance (4.2) | Real | The pre-ES2022 class pattern |
| Symbol keys (3.1) | Weak: visible through Reflect.ownKeys | Avoids name collisions, not access |
#private members | Real, enforced by the syntax | Modern default for classes |
Key Takeaways
staticmembers live on the class itself: use them for factories, constants and class-wide state. Inside static methods,thisis the class.static { ... }blocks run once at class definition time.#namecreates a truly private field, method or accessor. Outside access is a syntax error, and reflection cannot see it.- Private names are accessible to all code inside the class body, for any instance, but not to subclasses.
- Use
#field in objas a reliable brand check that an object was created by the class.
Practice
- (Beginner) Add a static
compare(a, b)method to aVersionclass (withmajor,minorandpatch), and use it to sort an array of versions withtoSorted(Version.compare).- (Intermediate) Implement a
Passwordclass that stores only a private hash (any simple function will do for practice), exposesverify(plain), and serialises to{"type":"password"}throughtoJSONso the hash never leaks. Verify that neitherJSON.stringify,Object.keysnorconsole.logreveal the hash.- (Interview) "What is the difference between a property named with a Symbol and a
#privatefield? Which one would a library use to avoid name collisions, and which to enforce invariants?" Hint: try to access each from outside the class withReflect.ownKeys, and consider what happens when a subclass or aProxyis involved.
Class Inheritance with extends and super
Often a new kind of object is "the same as an existing one, plus a little more": an AdminUser is a User with extra permissions, a ValidationError is an Error with a field name, a custom element is an HTMLElement with its own behaviour. Inheritance lets you reuse the parent's code and add to it or override parts. In JavaScript, extends builds the prototype links described in 4.4, and super lets the child call into the parent.
extends and super basics.
class Animal {
constructor(name) { this.name = name; }
speak() { return `${this.name} makes a sound`; }
static create(name) { return new this(name); } // this = whichever class it is called on
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // run Animal's constructor: required before using this
this.breed = breed;
}
speak() { // override
return `${super.speak()}: woof`; // call the parent's version
}
}
const rex = new Dog("Rex", "beagle");
console.log(rex.speak()); // "Rex makes a sound: woof"
console.log(rex instanceof Dog, rex instanceof Animal); // true true
console.log(Dog.create("Fido").speak()); // "Fido makes a sound: woof": static methods are inherited
Two prototype chains. class Dog extends Animal links instances to the parent's prototype and links the classes themselves, which is why static methods are inherited.
Instance chain Static (constructor) chain
rex ---> Dog.prototype ---> Animal.prototype Dog ---> Animal ---> Function.prototype
speak() speak() create()
constructor constructor
|
v
Object.prototype ---> null
Rules for derived constructors.
- If a subclass defines a constructor, it must call
super(...)before accessingthis. Otherwise it throws aReferenceError. The parent constructor is what creates and initialises the object. - If a subclass does not define a constructor, a default one is created:
constructor(...args) { super(...args); }. super.method(...)calls the parent's version of a method with the currentthis. It works in methods, getters, setters and static methods, and in object-literal shorthand methods.
class Broken extends Animal {
constructor(name) {
// this.tag = "x"; // ReferenceError: Must call super constructor before accessing 'this'
super(name);
this.tag = "x"; // OK after super()
}
}
Initialisation order and a classic pitfall. When you call new Child(): the child constructor starts, calls super(), the parent's fields are initialised and the parent constructor body runs, then the child's fields are initialised, and finally the rest of the child constructor runs. A parent constructor that calls an overridden method therefore sees a child whose fields do not exist yet.
class Widget {
constructor() { this.render(); } // calls the overridden render()
render() {}
}
class Label extends Widget {
text = "Hello"; // initialised only AFTER Widget's constructor returns
render() { console.log(`Rendering: ${this.text}`); }
}
new Label(); // "Rendering: undefined"
Avoid calling overridable methods from constructors. Use an explicit init() or render step called after construction instead.
Extending built-ins. With class, built-in constructors such as Error, Array, Map and HTMLElement can be subclassed correctly, something that was not possible with ES5 functions. Custom errors (3.4) and custom elements (Web Components) depend on this.
class HttpError extends Error {
constructor(status, message) {
super(message); // sets message and the stack trace
this.name = "HttpError";
this.status = status;
}
}
class Stack extends Array {
peek() { return this.at(-1); }
}
const s = Stack.from([1, 2, 3]); // static methods are inherited
console.log(s.peek(), s.map((x) => x * 2) instanceof Stack); // 3 true: map returns a Stack
Extending expressions. The expression after extends can be any expression that evaluates to a constructor (or null). This lets you compute the parent class at runtime, which is how mixins work (4.4): class Admin extends Serializable(User) {}.
Key Takeaways
class B extends AlinksB.prototypetoA.prototype(instances inherit methods) andBtoA(static methods are inherited).- A derived constructor must call
super(...)before usingthis. Omitting the constructor forwards all arguments to the parent. super.method()calls the parent's implementation with the currentthis.- Parent constructors run before child fields are initialised, so do not call overridable methods from a constructor.
classcan correctly extend built-ins such asError,ArrayandHTMLElement.
Practice
- (Beginner) Create
Vehiclewithwheelsanddescribe(), and subclassesCarandBikethat overridedescribe()while reusing the parent's text throughsuper.describe().- (Intermediate) Create a
ValidationError extends Errorwith afieldproperty, and anApiError extends Errorwithstatus. Write a function that catches errors from a fake API call and handles each type differently withinstanceof. Check thaterror.stackanderror.nameare correct for each.- (Interview) "In what order are fields initialised and constructors executed in a three-level hierarchy
A <- B <- C? Why might a method called fromA's constructor seeundefinedfor a field declared inC?" Hint: addconsole.logstatements in each field initialiser and constructor, and remember thatsuper()must return before the subclass's fields exist.
Getters, Setters and Accessors
Sometimes a value looks like a plain property to the outside but needs logic behind it. A fullName should always reflect the current first and last name. A celsius setter should reject temperatures below absolute zero. A width property should trigger a redraw when it changes. You could expose methods such as getFullName() and setWidth(w), but then every caller has to remember to use them, and switching a plain property to a method later breaks all callers. Accessor properties solve this: they are read and written with normal property syntax, but run a function each time.
Getters and setters. get name() runs when the property is read. set name(value) runs when it is assigned. You can define either or both, in object literals and in classes.
const person = {
first: "Ada",
last: "Lovelace",
get fullName() { // computed on every read
return `${this.first} ${this.last}`;
},
set fullName(value) { // runs on assignment
[this.first, this.last] = value.split(" ");
},
};
console.log(person.fullName); // "Ada Lovelace": no parentheses, it reads like data
person.fullName = "Grace Hopper"; // calls the setter
console.log(person.first); // "Grace"
Validation and derived values in a class. A private field stores the real value, and public accessors guard it.
class Temperature {
#celsius = 0;
get celsius() { return this.#celsius; }
set celsius(value) {
if (typeof value !== "number" || value < -273.15) {
throw new RangeError(`Invalid temperature: ${value}`);
}
this.#celsius = value;
}
get fahrenheit() { return this.#celsius * 9 / 5 + 32; } // derived
set fahrenheit(f) { this.celsius = (f - 32) * 5 / 9; } // reuses the celsius setter's validation
static get absoluteZero() { return -273.15; } // static getter
}
const t = new Temperature();
t.fahrenheit = 212;
console.log(t.celsius); // 100
// t.celsius = -300; // RangeError: Invalid temperature: -300
Lazy computation and caching. A getter can compute an expensive value only on first access and then replace itself with a plain data property, so later reads cost nothing.
const report = {
rows: Array.from({ length: 100_000 }, (_, i) => i),
get total() {
const value = this.rows.reduce((a, b) => a + b, 0); // computed once
Object.defineProperty(this, "total", { value, writable: false }); // replace accessor with data (4.4)
return value;
},
};
Where accessors live. In a class, getters and setters are defined on the prototype, not on each instance. This has consequences:
Object.keys(instance), spread andJSON.stringifydo not include them (unless you add atoJSON).- A setter on the parent class is bypassed if a subclass declares a field with the same name, because fields are defined directly on the instance.
Pitfalls.
"use strict";
class Bad {
set name(v) { this.name = v; } // assigns to itself: calls the setter again, forever
}
// new Bad().name = "x"; // RangeError: Maximum call stack size exceeded
class ReadOnly {
get id() { return 42; } // getter without a setter
}
// new ReadOnly().id = 1; // TypeError in strict mode: has only a getter (ignored in sloppy mode)
Store the backing value under a different name (a #private field is ideal) to avoid infinite recursion. Keep getters fast and free of side effects: a reader expects obj.x to be cheap and repeatable. Use a method instead when the work is expensive, asynchronous or changes state.
| Data property | Accessor property | |
|---|---|---|
| Stores a value | Yes | No (computes it) |
| Defined with | x: 1, this.x = 1, a field | get x() {}, set x(v) {} |
| Reading runs code | No | Yes |
| Can validate writes | No | Yes |
| Read-only form | writable: false (4.4) or freeze | Getter without a setter |
Key Takeaways
- Getters and setters let a property run code on read and write while keeping the
obj.propsyntax. - Use them for derived values, validation, lazy initialisation, and keeping a stable public API when the internals change.
- Store the underlying value elsewhere (ideally a
#privatefield) to avoid infinite recursion in setters. - Class accessors live on the prototype, so they are invisible to
Object.keys, spread and JSON. - A getter without a setter makes a read-only property. Assigning to it throws in strict mode.
Practice
- (Beginner) Add a
get area()and aget isSquare()to aRectangleclass, and aset size(n)that sets both width and height.- (Intermediate) Write a
Sliderclass with avalueaccessor that clamps the value to[min, max], rounds it to astep, and calls anonChangecallback only when the stored value actually changes. Makemin,maxandstepread-only after construction.- (Interview) "What is the difference between
get total() { ... }andtotal() { ... }on a class? When would you prefer each?" Hint: consider the call syntax, the expectations a reader has about cost and side effects, serialisation, and whether the value can take arguments.
Abstract Patterns and Duck Typing
Class-based languages let you declare an abstract class (a base class that cannot be instantiated and that forces subclasses to implement certain methods) and interfaces (a contract listing the methods a type must provide). The compiler then checks that every object passed to a function fulfils the contract. JavaScript has neither feature. Instead, it relies on conventions enforced at runtime and on duck typing: "if it walks like a duck and quacks like a duck, it is a duck". Code checks whether an object can do what is needed, not what class it belongs to.
Emulating an abstract class. new.target refers to the constructor that was called with new. A base class can refuse to be instantiated directly, and declare "abstract" methods that throw until a subclass overrides them.
class Exporter {
constructor() {
if (new.target === Exporter) { // only subclasses may be instantiated
throw new TypeError("Exporter is abstract");
}
}
// Template method: fixed algorithm, with a step provided by subclasses
export(rows) {
const body = rows.map((r) => this.formatRow(r)).join("\n");
return this.header() + body;
}
header() { return ""; } // optional hook with a default
formatRow(row) { // "abstract" method
throw new Error(`${this.constructor.name} must implement formatRow()`);
}
}
class CsvExporter extends Exporter {
header() { return "name,age\n"; }
formatRow({ name, age }) { return `${name},${age}`; }
}
console.log(new CsvExporter().export([{ name: "Ada", age: 36 }])); // "name,age\nAda,36"
// new Exporter(); // TypeError: Exporter is abstract
This is the template method pattern: the base class defines the algorithm, and subclasses fill in the steps. The checks happen at runtime, so a missing implementation is reported only when the method is called.
Duck typing. Instead of instanceof, check for the capability you are about to use. This makes code work with objects from other libraries, other frames, plain object literals and test doubles.
// Accept anything that has a write(text) method: a file stream, a DOM wrapper, a test mock
function logTo(sink, message) {
if (typeof sink?.write !== "function") {
throw new TypeError("sink must have a write(text) method");
}
sink.write(`[${new Date().toISOString()}] ${message}\n`);
}
const memorySink = { lines: [], write(text) { this.lines.push(text); } };
logTo(memorySink, "started"); // works: no class or inheritance needed
console.log(memorySink.lines.length); // 1
Protocols: the language's own duck typing. The language itself relies on duck typing through well-known method names and symbols. Implementing them lets your objects plug into built-in syntax.
| Protocol | Required member | Used by |
|---|---|---|
| Iterable | [Symbol.iterator]() returning an iterator | for...of, spread, Array.from, destructuring (3.3) |
| Thenable | then(onFulfilled, onRejected) | await, Promise.resolve (Chapter 5) |
| JSON | toJSON() | JSON.stringify (4.1) |
| Primitive conversion | [Symbol.toPrimitive](hint), valueOf(), toString() | Arithmetic, template literals, == (3.1) |
| Disposable | [Symbol.dispose]() | using declarations (explicit resource management, recent engines only) |
class Range {
constructor(from, to) { this.from = from; this.to = to; }
*[Symbol.iterator]() { // a generator method implements the iterable protocol
for (let i = this.from; i <= this.to; i++) yield i;
}
}
const r = new Range(1, 5);
console.log([...r]); // [1, 2, 3, 4, 5]
for (const n of r) if (n % 2 === 0) console.log(n); // 2, 4
const isIterable = (x) => x != null && typeof x[Symbol.iterator] === "function";
console.log(isIterable(r), isIterable({})); // true false
Duck typing versus nominal checks.
Duck typing (typeof x.m === "function") | Nominal (x instanceof C) | |
|---|---|---|
| Accepts | Any object with the right shape | Only objects whose prototype chain contains C.prototype |
| Objects from other frames or library copies | Work | Fail (4.4) |
| Test doubles | Plain objects work | Must extend or fake the class |
| Risk | Accidental matches (an unrelated write method) | Over-restrictive |
For compile-time checking of shapes, TypeScript adds structural interfaces on top of JavaScript (Chapter 9). They formalise duck typing without changing runtime behaviour.
Key Takeaways
- JavaScript has no
abstractorinterfacekeywords. Emulate abstract classes withnew.targetchecks and methods that throw until overridden. - The template method pattern puts the algorithm in the base class and the variable steps in subclasses.
- Duck typing checks capabilities (
typeof obj.method === "function") rather than ancestry, which makes code more flexible and easier to test. - Built-in features rely on protocols such as iterables, thenables and
toJSON. Implement them to integrate your objects with the language. - TypeScript interfaces add structural, compile-time checks of the same idea.
Practice
- (Beginner) Make a
Playlistclass iterable over its songs with[Symbol.iterator], so that[...playlist]andfor...ofwork.- (Intermediate) Create an abstract
Storagebase class with abstractget(key)andset(key, value), and two implementations:MemoryStorage(using aMap) andLocalStorage(wrappingwindow.localStoragewith JSON). Write aSettingsclass that works with either, and test it with a plain object literal that has the same two methods.- (Interview) "What is a thenable, and why does
awaitaccept objects that are notPromiseinstances?" Hint: relate it to duck typing and to interoperability between promise libraries written before native promises existed.
<a id="44-implementation-of-objects-and-constructors"></a>
4.4 Implementation of Objects and Constructors
This subchapter looks beneath the class syntax at the machinery JavaScript actually uses: constructor functions, the prototype chain, property descriptors, delegation, type checks, meta-programming with Proxy and Reflect, and how the engine stores objects and reclaims their memory.
Constructor Functions and the new Operator
Long before class existed, JavaScript code created many objects of the same kind with constructor functions: ordinary functions meant to be called with the new operator. Classes compile down to the same mechanism, most older libraries and many tutorials still use the pattern, and the built-ins (Date, Error, Map) behave like constructors. Understanding exactly what new does is the key to understanding prototypes.
The pattern. By convention, constructor names start with a capital letter. Per-instance data is assigned to this inside the function. Shared methods are put on the function's prototype object, so all instances share one copy.
function Person(name, born) {
this.name = name; // own data for each instance
this.born = born;
}
Person.prototype.age = function (year = new Date().getFullYear()) { // shared method
return year - this.born;
};
const ada = new Person("Ada", 1815);
const alan = new Person("Alan", 1912);
console.log(ada.age(1852), alan.name); // 37 "Alan"
console.log(ada.age === alan.age); // true: one function shared through the prototype
What new F(args) does, step by step:
- Creates a new empty object.
- Sets that object's internal
[[Prototype]]link toF.prototype. - Calls
Fwiththisbound to the new object, passingargs. - If
Freturns an object, that object becomes the result. Otherwise (noreturn, or a primitive), the new object from step 1 is the result.
new Person("Ada", 1815)
1. obj = {}
2. obj.[[Prototype]] = Person.prototype obj ----> Person.prototype ----> Object.prototype
3. Person.call(obj, "Ada", 1815) age()
obj.name = "Ada"; obj.born = 1815
4. return obj (unless Person returned an object)
The four steps can be written as an ordinary function, which is a common interview exercise:
function myNew(F, ...args) {
const obj = Object.create(F.prototype); // steps 1 and 2
const result = F.apply(obj, args); // step 3
return result !== null && (typeof result === "object" || typeof result === "function")
? result // step 4: an explicitly returned object wins
: obj;
}
const grace = myNew(Person, "Grace", 1906);
console.log(grace.age(1950), grace instanceof Person); // 44 true
Forgetting new. A constructor called without new is just a function call, and this follows the default binding (4.3). In sloppy mode, this is the global object, so properties leak onto window. In strict mode, this is undefined and the first assignment throws. Defensive constructors check new.target, which is undefined when the function was not called with new:
function Point(x, y) {
if (!new.target) return new Point(x, y); // repair the call (or throw a TypeError)
this.x = x;
this.y = y;
}
console.log(Point(1, 2) instanceof Point); // true
Classes make this check automatic: calling a class without new always throws.
What can be a constructor. Regular function declarations and expressions and classes have a prototype and can be used with new. Arrow functions, shorthand methods, generators and async functions cannot: new (() => {}) throws TypeError: ... is not a constructor.
Built-in constructors with and without new. Some built-ins behave differently when called without new: Date() returns a string, while new Date() returns a Date object. String(x), Number(x) and Boolean(x) convert, while their new forms create wrapper objects (3.1). Array(3) and new Array(3) are equivalent, as are Error("x") and new Error("x"). Symbol and BigInt must not be called with new.
Key Takeaways
- A constructor function sets per-instance data on
this. Methods placed onF.prototypeare shared by all instances. new F()creates an object linked toF.prototype, runsFwiththisset to it, and returns it unlessFreturns another object.- Without
new,thisis the global object (sloppy) orundefined(strict). Guard withnew.target, or useclass. - Arrow functions, methods, generators and async functions cannot be constructors.
Practice
- (Beginner) Write a
Book(title, pages)constructor with a prototype methodreadingTime(pagesPerHour). Create two books and confirm that they share the method but not the data.- (Intermediate) Extend
myNewso that it throws aTypeErrorwhenFis not constructible (hint: arrow functions have noprototypeproperty). Test it with a function that explicitly returns an object, one that returns a primitive, and a class (and explain why calling a class withF.applyfails).- (Interview) "Walk me through what happens, step by step, when the engine evaluates
new Foo(1)." Hint: cover object creation, the prototype link,this, and the special rule for the return value.
The Prototype Chain
When you call [1, 2].map(...), the array has no own property called map, yet the call works. When you call toString() on an object literal you never gave a toString, it also works. Both work because of the prototype chain, the mechanism behind all inheritance in JavaScript. Instead of copying methods into every object, objects delegate lookups they cannot satisfy to another object. It is similar to asking a colleague: if you do not know the answer, you ask your team lead, who may ask the department head, and so on until someone knows or the chain ends.
The [[Prototype]] link. Every object has a hidden internal slot called [[Prototype]] that refers to another object or to null. Read it with Object.getPrototypeOf(obj). Following these links from object to object forms the chain, which always ends at null.
const arr = [1, 2];
arr Array.prototype Object.prototype null
+-------------+ +-----------------+ +------------------+
| 0: 1 | ---> | map, filter, | ---> | toString, | ---> null
| 1: 2 | | push, at, ... | | hasOwnProperty, |
| length: 2 | | constructor | | valueOf, ... |
+-------------+ +-----------------+ +------------------+
arr.map -> not own -> found on Array.prototype
arr.toString -> not own -> Array.prototype has its own toString, found there first
arr.valueOf -> not own -> not on Array.prototype -> found on Object.prototype
arr.missing -> not own -> not found anywhere -> reaches null -> undefined
Reading walks the chain, writing does not. A property read searches the object itself, then its prototype, and so on. The first match wins. A property write (obj.x = 1) normally creates or updates an own property on obj itself, even if the prototype has a property with the same name. The own property then shadows the inherited one.
const defaults = { color: "blue", size: "M" };
const order = Object.create(defaults); // order's prototype is defaults (see Object.create)
order.size = "L"; // creates an own property: shadows defaults.size
console.log(order.color); // "blue": inherited
console.log(order.size); // "L": own property found first
console.log(defaults.size); // "M": the prototype was not modified
delete order.size; // remove the own property...
console.log(order.size); // "M": ...and the inherited one is visible again
Two exceptions to "writes create own properties": if the inherited property is an accessor with a setter, the setter runs instead (4.3). If it is a read-only data property (writable: false), the write fails, and throws in strict mode.
The chain is live. Prototypes are ordinary objects referenced by link, not copied. Adding a method to a prototype makes it instantly available to every object that delegates to it, including objects created earlier.
function Dog(name) { this.name = name; }
const rex = new Dog("Rex");
Dog.prototype.bark = function () { return `${this.name}: woof`; }; // added after rex was created
console.log(rex.bark()); // "Rex: woof"
This is how polyfills (Chapter 1) add missing methods to built-ins. Modifying built-in prototypes in application code ("monkey patching"), however, risks clashing with future standard methods and with other libraries, and is strongly discouraged.
Shared mutable state on prototypes. Because every instance sees the same prototype object, a mutable value stored there is shared by all instances. That is right for methods and wrong for data:
function Team() {}
Team.prototype.members = []; // one array for ALL teams
const a = new Team(), b = new Team();
a.members.push("Ada"); // reading a.members finds the prototype's array, then mutates it
console.log(b.members); // ["Ada"]: b sees it too
Put per-instance data in the constructor or in class fields.
Objects without a prototype. Object.create(null) creates an object whose chain ends immediately. It has no inherited toString, hasOwnProperty or __proto__, which makes it a safe dictionary for arbitrary string keys (although a Map is usually better, 4.2).
Performance. Lookups along short chains are very fast, because engines cache where they found each property (see Memory Model and Garbage Collection). Very deep chains and, above all, changing an existing object's prototype at runtime with Object.setPrototypeOf defeat these optimisations. Set the prototype when you create the object instead.
Key Takeaways
- Every object has a
[[Prototype]]link to another object ornull. Read it withObject.getPrototypeOf. - Reading a property searches the object, then its prototypes in order, and returns
undefinedif the chain ends atnull. - Writing a property creates an own property that shadows the inherited one, unless the inherited property is a setter or read-only.
- Prototypes are shared and live: changes are visible to every object linked to them. Keep methods on prototypes and data on instances.
- Avoid
Object.setPrototypeOfon existing objects, and do not modify built-in prototypes in application code.
Practice
- (Beginner) Write a function
chainOf(obj)that returns an array of the prototypes ofobjup tonull, usingObject.getPrototypeOfin a loop. Try it with[],{},new Date(),function () {}andObject.create(null).- (Intermediate) Create
base = { greet() { return "hi " + this.name; } }and two objects that delegate to it. Show shadowing by giving one of them its owngreet, then usedeleteto restore the inherited behaviour. Then makebase.greetread-only withObject.definePropertyand observe what happens when you try to shadow it in strict mode.- (Interview) "Explain how
'abc'.toUpperCase()and[].push(1)find their methods, given that the values have no such own properties." Hint: combine autoboxing (3.1) with the prototype chain ofString.prototypeandArray.prototype.
prototype vs Dunder Proto
Two similar names cause more confusion than anything else in JavaScript's object model: the prototype property of functions, and __proto__ (read "dunder proto", short for "double underscore proto"). They are related but mean completely different things. A function's prototype is the object that will become the [[Prototype]] of instances created with new. An object's __proto__ is a getter and setter for its own [[Prototype]] link.
Side by side.
F.prototype | obj.__proto__ | |
|---|---|---|
| Exists on | Constructible functions and classes | Objects that inherit from Object.prototype |
| Kind | An ordinary data property | An accessor (getter/setter) defined on Object.prototype |
| Meaning | "Instances created by new F will link to this object" | "The object obj currently links to" |
| Standard status | Core language | Legacy, kept for compatibility (Annex B). Avoid it. |
| Modern replacement | Object.getPrototypeOf(obj) / Object.setPrototypeOf(obj, p) |
The triangle. For any constructor F and instance x = new F():
new F()
F -----------------------> x
| |
| .prototype | [[Prototype]] (read via Object.getPrototypeOf(x),
v v or the legacy x.__proto__)
F.prototype <-----------------+
|
| .constructor (points back to F)
v
F
function F() {}
const x = new F();
console.log(F.prototype === Object.getPrototypeOf(x)); // true: the core relationship
console.log(x.__proto__ === F.prototype); // true: legacy accessor, same link
console.log(F.prototype.constructor === F); // true: the back-reference
console.log(x.constructor === F); // true: inherited from F.prototype
console.log(Object.hasOwn(x, "prototype")); // false: ordinary objects have no .prototype
console.log(Object.getPrototypeOf(F) === Function.prototype); // true: F itself is a function object
console.log(F.prototype === Function.prototype); // false: two very different objects
The last two lines show the most common confusion. F.prototype is where instances of F look for methods. F's own [[Prototype]] is Function.prototype, which is where F itself finds call, apply and bind.
Why __proto__ is discouraged. It works only on objects that inherit from Object.prototype, because it is an inherited accessor. On an object created with Object.create(null), obj.__proto__ is just an ordinary key. It is also a well-known attack vector: code that copies user-controlled keys into objects (for example, a naive deep merge of parsed JSON) can be tricked into writing to __proto__ and modifying Object.prototype for the entire application. This attack is called prototype pollution.
function naiveMerge(target, source) {
for (const key in source) {
if (typeof source[key] === "object" && source[key] !== null) {
target[key] ??= {};
naiveMerge(target[key], source[key]); // recurses into target["__proto__"] = Object.prototype!
} else {
target[key] = source[key];
}
}
return target;
}
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}'); // JSON.parse creates an own "__proto__" key
naiveMerge({}, payload);
console.log({}.isAdmin); // true: every object in the program is now "admin"
delete Object.prototype.isAdmin; // clean up
// Defences: skip "__proto__", "constructor" and "prototype" keys, use Object.hasOwn,
// build dictionaries with Object.create(null) or Map, or freeze Object.prototype
__proto__ in object literals is different. Inside an object literal, __proto__: value (non-computed and not shorthand) is standard syntax that sets the new object's prototype. It is a concise and well-optimised way to create an object with a given prototype:
const animal = { eats: true };
const rabbit = { __proto__: animal, jumps: true }; // sets [[Prototype]], creates no own key
console.log(rabbit.eats, Object.hasOwn(rabbit, "__proto__")); // true false
const literalKey = { ["__proto__"]: animal }; // computed key: an ordinary own property
console.log(Object.hasOwn(literalKey, "__proto__")); // true
Key Takeaways
F.prototypeis a property of functions: the object that becomes the[[Prototype]]ofnew F()instances.obj.__proto__is a legacy accessor forobj's own[[Prototype]]. UseObject.getPrototypeOfandObject.setPrototypeOfinstead.F.prototype.constructor === F, and instances inherit thatconstructorback-reference.- A function's own prototype is
Function.prototype, which is different from itsprototypeproperty. - Merging untrusted keys can write through
__proto__and polluteObject.prototype. Filter dangerous keys or use null-prototype objects andMap.
Practice
- (Beginner) For
class A {}andconst a = new A(), evaluate and explain:A.prototype === Object.getPrototypeOf(a),Object.getPrototypeOf(A) === Function.prototype,a.prototype,a.constructor === A,A.prototype.constructor === A.- (Intermediate) Fix
naiveMergeso that it is safe against prototype pollution, and write a test that proves it using the malicious payload above plus aconstructor.prototypevariant ({"constructor": {"prototype": {"polluted": true}}}).- (Interview) "What is the difference between
Foo.prototypeandfoo.__proto__, and when are they the same object?" Hint: draw the triangle between a constructor, itsprototypeobject and an instance.
Object.create and Prototypal Delegation
Constructors and classes are one way to set up prototype links, but they hide the link behind new. Object.create(proto) makes the link explicit: it creates a new, empty object whose [[Prototype]] is exactly the object you pass. This exposes the essence of JavaScript's object model: objects linking to other objects, with no class in between. Thinking in terms of delegation instead of "classes and instances" often makes the model easier to reason about.
Object.create(proto, descriptors?). The first argument is the prototype (an object or null). The optional second argument defines own properties using property descriptors (see Property Descriptors and defineProperty).
const vehicle = {
describe() { return `${this.name} has ${this.wheels} wheels`; },
};
const bike = Object.create(vehicle); // bike --[[Prototype]]--> vehicle
bike.name = "Bike";
bike.wheels = 2;
console.log(bike.describe()); // "Bike has 2 wheels": delegated to vehicle
const car = Object.create(vehicle, {
name: { value: "Car", enumerable: true },
wheels: { value: 4, enumerable: true },
});
console.log(car.describe()); // "Car has 4 wheels"
console.log(Object.getPrototypeOf(car) === vehicle); // true
Delegation versus copying. In a class-based language, an instance gets its own copy of the structure defined by its class. In JavaScript, an object holds only its own data and delegates everything else to its prototype at lookup time. Compare a tutorial copied onto each student's desk (copying) with one shared reference book that everyone consults (delegation): updating the shared book updates it for everyone at once.
OLOO: "objects linked to other objects". You can design whole programs with plain objects and Object.create, without constructors or classes. Behaviour lives in prototype objects, and an init method replaces the constructor.
const Widget = {
init(width, height) {
this.width = width;
this.height = height;
return this;
},
render() { return `<div style="width:${this.width}px;height:${this.height}px"></div>`; },
};
const Button = Object.create(Widget); // Button delegates to Widget
Button.setup = function (width, height, label) {
this.init(width, height); // delegated call, this = the button object
this.label = label;
return this;
};
Button.render = function () {
return Widget.render.call(this).replace("></div>", `>${this.label}</div>`); // explicit "super" call
};
const ok = Object.create(Button).setup(80, 30, "OK"); // ok -> Button -> Widget -> Object.prototype
console.log(ok.render()); // '<div style="width:80px;height:30px">OK</div>'
Pre-ES2015 inheritance between constructors. Before extends, inheritance between constructor functions was wired by hand with Object.create. You will still see this in older libraries, and it is exactly what extends does for you:
function Animal(name) { this.name = name; }
Animal.prototype.speak = function () { return `${this.name} makes a sound`; };
function Dog(name) {
Animal.call(this, name); // like super(name): run the parent constructor
}
Dog.prototype = Object.create(Animal.prototype); // link the prototypes
Dog.prototype.constructor = Dog; // restore the back-reference that was overwritten
Dog.prototype.speak = function () {
return Animal.prototype.speak.call(this) + ": woof"; // like super.speak()
};
console.log(new Dog("Rex").speak()); // "Rex makes a sound: woof"
Three ways to build the same model.
| Constructor function | class | Object.create (OLOO) | |
|---|---|---|---|
| Create instance | new F() | new C() | Object.create(proto).init() |
| Shared behaviour on | F.prototype | C.prototype (implicit) | The prototype object itself |
| Inheritance | Object.create plus manual wiring | extends | Object.create(parentProto) |
| Parent calls | Parent.prototype.m.call(this) | super.m() | Parent.m.call(this) |
instanceof | Works | Works | Use proto.isPrototypeOf(obj) |
| Private state | Closures | #private | Closures or WeakMap |
Key Takeaways
Object.create(proto)creates an object whose[[Prototype]]isproto.Object.create(null)creates one with no prototype.- Delegation means objects share behaviour by linking to a prototype at lookup time, not by copying it.
- OLOO designs use plain objects,
Object.createandinitmethods instead of constructors. - Before
extends, inheritance was set up withChild.prototype = Object.create(Parent.prototype), restoringconstructor, and callingParent.call(this, ...).
Practice
- (Beginner) Use
Object.createto makeadmindelegate touser, which delegates toperson. Give each level one method, and call all three methods onadmin.- (Intermediate) Implement a minimal
Object.createpolyfillcreate(proto)using only a temporary constructor function andnew, as was done before ES5. Explain why your version cannot supportnullas the prototype.- (Interview) "Why do we write
Dog.prototype.constructor = DogafterDog.prototype = Object.create(Animal.prototype)? What breaks if we forget?" Hint: check whatnew Dog().constructorreturns in both cases and where that property is found.
Property Descriptors and defineProperty
So far, a property has looked like a simple key-value pair. In reality, every property also carries hidden attributes that control whether it can be changed, whether it shows up in loops, and whether it can be deleted or reconfigured. These attributes explain why Math.PI cannot be reassigned, why array methods do not appear in for...in, and how Object.freeze works. You can read and set them with property descriptors.
Two kinds of properties, two kinds of descriptors.
| Attribute | Data property | Accessor property | Meaning |
|---|---|---|---|
value | Yes | The stored value | |
writable | Yes | Can the value be changed by assignment? | |
get | Yes | Function called on read | |
set | Yes | Function called on write | |
enumerable | Yes | Yes | Does it appear in for...in, Object.keys, spread and JSON? |
configurable | Yes | Yes | Can it be deleted, and can its attributes be changed? |
Reading descriptors.
const user = { name: "Ada" };
console.log(Object.getOwnPropertyDescriptor(user, "name"));
// { value: "Ada", writable: true, enumerable: true, configurable: true }
console.log(Object.getOwnPropertyDescriptor(Math, "PI"));
// { value: 3.141592653589793, writable: false, enumerable: false, configurable: false }
console.log(Object.getOwnPropertyDescriptor(Array.prototype, "map").enumerable); // false
Defining properties precisely. Object.defineProperty(obj, key, descriptor) creates or modifies a single property. Object.defineProperties(obj, { key: descriptor, ... }) does several at once. The crucial detail is the defaults: attributes you omit default to false when a property is created by defineProperty, whereas properties created by assignment or literals have every attribute set to true.
"use strict";
const order = { items: [] };
Object.defineProperty(order, "id", {
value: "ord_42", // writable, enumerable and configurable all default to false
});
Object.defineProperty(order, "total", {
get() { return this.items.reduce((s, i) => s + i.price, 0); },
enumerable: true, // show it in Object.keys and JSON
});
order.items.push({ price: 10 }, { price: 5 });
console.log(order.total); // 15
console.log(Object.keys(order)); // ["items", "total"]: id is hidden (not enumerable)
console.log(JSON.stringify(order)); // '{"items":[...],"total":15}'
try { order.id = "hacked"; } catch (e) { console.log(e.constructor.name); } // "TypeError": not writable
try { delete order.id; } catch (e) { console.log(e.constructor.name); } // "TypeError": not configurable
configurable: false is (almost) permanent. Once a property is non-configurable, you cannot delete it, turn it into an accessor, or change its enumerable flag. The only change still allowed is lowering writable from true to false (and changing value while it is still writable). Attempts to do anything else throw a TypeError.
How the integrity functions use descriptors. The functions from 4.1 are shortcuts over descriptors:
| Function | Effect in descriptor terms |
|---|---|
Object.preventExtensions(o) | Marks o non-extensible. Existing descriptors unchanged. |
Object.seal(o) | Non-extensible, plus configurable: false on every own property |
Object.freeze(o) | Sealed, plus writable: false on every data property (setters still run) |
Copying with descriptors. Spread and Object.assign read values, so getters are evaluated once and copied as plain values, and non-enumerable properties are skipped (4.1). To copy properties as they are, including accessors and attributes, copy the descriptors:
const source = {
first: "Ada",
last: "Lovelace",
get full() { return `${this.first} ${this.last}`; },
};
const flat = { ...source };
flat.first = "Grace";
console.log(flat.full); // "Ada Lovelace": full was copied as a frozen-in-time string
const faithful = Object.defineProperties({}, Object.getOwnPropertyDescriptors(source));
faithful.first = "Grace";
console.log(faithful.full); // "Grace Lovelace": the getter was copied
// A shallow clone that also keeps the prototype
const clone = (o) => Object.create(Object.getPrototypeOf(o), Object.getOwnPropertyDescriptors(o));
Typical uses. Hidden metadata (enumerable: false) that should not appear in JSON or loops; constants on objects (writable: false); lazily computed properties (see Getters, Setters and Accessors); and libraries that add properties to objects they do not own without disturbing enumeration, as polyfills do.
Key Takeaways
- Data properties have
valueandwritable. Accessor properties havegetandset. Both haveenumerableandconfigurable. - Properties created by assignment or literals have every attribute set to
true. Attributes omitted indefinePropertydefault tofalse. - Non-writable properties reject assignment, non-enumerable properties are hidden from enumeration and JSON, and non-configurable properties cannot be deleted or redefined.
sealandfreezework by settingconfigurableandwritabletofalseon every property.- Use
Object.getOwnPropertyDescriptorswithdefinePropertiesorObject.createto copy accessors faithfully.
Practice
- (Beginner) Print the descriptors of
Math.PI, of"length"on an array, and of a property you create with an object literal. Explain each attribute's value.- (Intermediate) Write
defineConstants(obj, map)that adds every entry ofmaptoobjas a non-writable, non-configurable, enumerable property. Then writehide(obj, key)that makes an existing property non-enumerable without changing its value. Verify both withObject.keysandJSON.stringify.- (Interview) "Why can't you
delete Math.PIor reassign it, and how could you create a property with the same behaviour on your own object?" Hint: inspect its descriptor, and recall the default attribute values ofObject.defineProperty.
Method Borrowing and Mixins
Inheritance gives an object one parent. Real designs often need behaviour from several unrelated sources: a User that is also serialisable, observable and validatable, or an array-like NodeList that would benefit from an array method. Two techniques share behaviour without forcing a class hierarchy. Method borrowing calls another object's method on your object for a single use. Mixins permanently add a set of methods to a class or object.
Method borrowing. Most built-in methods are intentionally generic: they only require this to look like the right kind of object. With call and apply (3.3), you can run them on any compatible object.
// Array methods on array-likes (4.2)
function joinArgs() {
return Array.prototype.join.call(arguments, "-"); // arguments has no join method
}
console.log(joinArgs("a", "b", "c")); // "a-b-c"
// Object.prototype methods on objects that might shadow or lack them
const dict = Object.create(null);
dict.key = 1;
console.log(Object.prototype.hasOwnProperty.call(dict, "key")); // true (modern: Object.hasOwn)
// Accurate type tags (see instanceof and Type Introspection)
console.log(Object.prototype.toString.call([])); // "[object Array]"
// Functions that expect separate arguments, applied to an array
console.log(Math.max.apply(null, [3, 9, 2])); // 9 (modern: Math.max(...arr))
Object mixins with Object.assign. A mixin is an object containing methods intended to be copied into other objects or prototypes. Object.assign copies them onto a class's prototype, so every instance gains them.
const Serializable = {
serialize() { return JSON.stringify(this); },
};
const Observable = {
on(event, fn) {
((this._handlers ??= {})[event] ??= []).push(fn); // lazily create storage per instance
return this;
},
emit(event, data) {
(this._handlers?.[event] ?? []).forEach((fn) => fn(data));
},
};
class User {
constructor(name) { this.name = name; }
}
Object.assign(User.prototype, Serializable, Observable); // copy methods onto the prototype
const u = new User("Ada");
u.on("rename", (n) => console.log(`renamed to ${n}`));
u.emit("rename", "Grace"); // "renamed to Grace"
console.log(u.serialize()); // '{"name":"Ada","_handlers":{...}}'
Notice the problems with this approach. Methods with the same name silently overwrite each other (the last mixin wins). Object.assign evaluates getters instead of copying them (see Property Descriptors and defineProperty). Mixin state (_handlers) leaks into JSON, because mixins cannot use #private fields. And super does not work inside copied methods as it would in a class.
Subclass factory mixins. A more robust pattern uses the fact that extends accepts any expression (4.3). Each mixin is a function that takes a base class and returns a subclass of it. Applying several mixins builds a linear inheritance chain, so super works and there are no silent overwrites: a later mixin overrides and can call the earlier one.
const Timestamped = (Base) => class extends Base {
createdAt = new Date();
touch() { this.updatedAt = new Date(); return this; }
};
const Validated = (Base) => class extends Base {
validate() {
if (!this.name) throw new Error("name is required");
return super.validate?.() ?? true; // chains to an earlier validate if present
}
};
class Entity { constructor(name) { this.name = name; } }
class Product extends Validated(Timestamped(Entity)) {}
const p = new Product("Lamp").touch();
console.log(p.validate(), p.createdAt instanceof Date); // true true
Product -> Validated(...) -> Timestamped(...) -> Entity -> Object
(each mixin application creates one anonymous class in the chain)
| Method borrowing | Object.assign mixin | Subclass factory mixin | |
|---|---|---|---|
| Scope | One call | Every instance (copied onto the prototype) | Every instance (through the chain) |
| Name collisions | Not applicable | Silent overwrite | Explicit override, super available |
| Accessors | Not applicable | Flattened to values | Preserved |
| Private fields | Not applicable | Not possible | Supported inside each mixin |
instanceof the mixin | No | No | Not directly (each application is a new class) |
Key Takeaways
- Method borrowing runs a generic method on another object with
callorapply, for example array methods on array-likes. Object.assign(C.prototype, mixin)adds methods to every instance, but collisions overwrite silently and getters are flattened.- Subclass factory mixins (
Base => class extends Base {}) compose behaviour into a linear chain that supportssuper, accessors and private fields. - Mixins are a form of composition: use them for orthogonal capabilities (serialisation, events, timestamps), not for "is a" relationships.
Practice
- (Beginner) Use
Array.prototype.filter.callon aNodeListor on{ 0: 3, 1: 8, 2: 5, length: 3 }to keep only the values greater than 4.- (Intermediate) Write an
EventEmittersubclass factory mixin withon,offandemitthat stores handlers in a#privateMap. Apply it to two unrelated classes and show that their handlers do not leak intoJSON.stringify.- (Interview) "JavaScript only supports single inheritance. How would you give a class behaviour from two independent sources, and what are the trade-offs?" Hint: compare copying methods onto the prototype with chaining class factories, and mention composition by holding helper objects as fields.
instanceof and Type Introspection
Code often needs to know what kind of value it has received: an error handler that treats HttpError differently from TypeError, a function that accepts either a string or an array, a serialiser that handles dates specially. JavaScript offers several ways to ask "what is this?", and each has blind spots. typeof (3.1) only distinguishes broad categories. This topic covers the tools for objects and when each one can be trusted.
How instanceof works. x instanceof C does not check how x was created. It walks x's prototype chain and returns true if it finds C.prototype anywhere on it.
function myInstanceof(x, C) {
if (x === null || (typeof x !== "object" && typeof x !== "function")) return false; // primitives
let proto = Object.getPrototypeOf(x);
while (proto !== null) {
if (proto === C.prototype) return true;
proto = Object.getPrototypeOf(proto); // keep climbing the chain
}
return false;
}
class Animal {}
class Dog extends Animal {}
const d = new Dog();
console.log(d instanceof Dog, d instanceof Animal, d instanceof Object); // true true true
console.log(myInstanceof(d, Animal)); // true
Where instanceof misleads.
// 1. Primitives are never instances
console.log("text" instanceof String); // false: a primitive, not a String object
// 2. The prototype chain can be changed after creation
const fake = Object.setPrototypeOf({}, Dog.prototype);
console.log(fake instanceof Dog); // true, although no Dog constructor ever ran
// 3. Objects from another realm (iframe, worker message, vm context) have different built-ins
const iframe = document.createElement("iframe");
document.body.append(iframe);
const FrameArray = iframe.contentWindow.Array;
const foreign = new FrameArray(1, 2);
console.log(foreign instanceof Array); // false: a different Array.prototype
console.log(Array.isArray(foreign)); // true: realm-independent check
The cross-realm problem matters in browsers (iframes, postMessage), in Node.js (vm contexts), and whenever two copies of the same library are loaded in a bundle: each copy has its own class, so instanceof fails between them.
Customising instanceof. A class can define static [Symbol.hasInstance](x) to decide what counts as an instance. This is rarely needed, but it allows duck-typed instanceof checks:
class Iterable {
static [Symbol.hasInstance](x) { return x != null && typeof x[Symbol.iterator] === "function"; }
}
console.log([1] instanceof Iterable, "abc" instanceof Iterable, {} instanceof Iterable);
// true true false
Other introspection tools.
| Tool | Answers | Notes |
|---|---|---|
typeof x | Primitive type, or "object" / "function" | typeof null === "object" (3.1) |
x instanceof C | Is C.prototype on x's chain? | Fails across realms. false for primitives. |
proto.isPrototypeOf(x) | Is proto on x's chain? | Works for OLOO objects with no constructor |
Object.getPrototypeOf(x) === C.prototype | Is x a direct instance of C? | Excludes subclasses |
x.constructor === C / x.constructor.name | What constructor does x claim? | Easily overwritten. Missing on null-prototype objects. |
Array.isArray(x) | Is x an array? | Realm-safe. The right way to detect arrays. |
Object.prototype.toString.call(x) | Built-in tag such as "[object Date]" | Realm-safe for built-ins. Customisable with Symbol.toStringTag. |
#field in x | Was x constructed by this class? | Unforgeable brand check (4.3) |
| Duck typing | Does x have the methods I need? | Most flexible (4.3) |
const tag = (x) => Object.prototype.toString.call(x).slice(8, -1); // "[object Date]" -> "Date"
console.log(tag(null), tag([]), tag(new Date()), tag(/x/), tag(new Map())); // Null Array Date RegExp Map
class Temperature {
get [Symbol.toStringTag]() { return "Temperature"; } // custom tag
}
console.log(tag(new Temperature())); // "Temperature"
console.log(String(new Temperature())); // "[object Temperature]"
Choosing a check. Use typeof for primitives and functions, Array.isArray for arrays, instanceof for your own classes and errors within one program, #field in obj when you must be certain an object came from your class, and duck typing when you only need a capability.
Key Takeaways
x instanceof Cchecks whetherC.prototypeappears anywhere onx's prototype chain. It does not check howxwas created.instanceofreturnsfalsefor primitives and fails for objects from other realms or from duplicate copies of a library.- Use
Array.isArrayfor arrays andObject.prototype.toString.callfor built-in type tags. Both are realm-safe. constructorandtoStringTagcan be overwritten, so treat them as hints. Private brand checks (#x in obj) cannot be forged.- Prefer duck typing when you only need to know what an object can do.
Practice
- (Beginner) Write
describe(value)that returns"null","array","date", a class name for class instances, or thetypeofresult otherwise. Test it with ten different values.- (Intermediate) Create an iframe, construct a
Dateand anErrorinside it, and compare the results ofinstanceof,Object.prototype.toString.calland duck typing (typeof x.getTime === "function") for each. Explain everyfalse.- (Interview) "How does
instanceofwork, and how can it give a wrong answer?" Hint: implement it withObject.getPrototypeOfin a loop, then think ofsetPrototypeOf, multiple realms,Symbol.hasInstanceand primitives.
Proxy and Reflect APIs
Normally, the operations performed on an object (reading a property, assigning one, checking in, deleting, calling a function) have fixed built-in behaviour. A Proxy lets you intercept these fundamental operations and redefine them. It wraps a target object, and a handler object provides traps, functions that run instead of the default behaviour. Think of a receptionist in front of an office: every request goes through them, and they can forward it unchanged, modify it, log it, or refuse it. The companion Reflect object provides the default behaviour of every operation as a plain function, so traps can easily forward requests to the target. Together they enable validation layers, default values, logging, and the reactivity systems of frameworks such as Vue 3 and MobX.
Basic structure.
const target = { name: "Ada" };
const handler = {
get(target, key, receiver) { // intercepts property reads
console.log(`read ${String(key)}`);
return Reflect.get(target, key, receiver); // forward to the default behaviour
},
};
const proxy = new Proxy(target, handler);
console.log(proxy.name); // logs "read name", then "Ada"
proxy.age = 36; // no set trap: the default runs and writes to target
console.log(target.age); // 36
The main traps. Every internal operation has a trap with the same name as its Reflect method.
Trap / Reflect method | Intercepts |
|---|---|
get(target, key, receiver) | proxy.key, proxy[key] |
set(target, key, value, receiver) | proxy.key = value (must return true on success) |
has(target, key) | key in proxy |
deleteProperty(target, key) | delete proxy.key |
ownKeys(target) | Object.keys, Reflect.ownKeys, for...in |
getOwnPropertyDescriptor / defineProperty | Descriptor reads and Object.defineProperty |
getPrototypeOf / setPrototypeOf | Prototype access, instanceof |
apply(target, thisArg, args) | Calling a proxied function |
construct(target, args, newTarget) | new proxy(...) |
Why Reflect exists. Reflect methods do exactly what the language would do by default, but with a function-call interface. They return booleans instead of throwing for operations that can fail (Reflect.set, Reflect.defineProperty), and they take a receiver argument so getters and setters run with the correct this. Forwarding with Reflect.get(target, key, receiver) rather than target[key] keeps inherited getters working correctly through the proxy.
Example: validation.
"use strict";
function typed(schema) {
return new Proxy({}, {
set(target, key, value, receiver) {
const expected = schema[key];
if (!expected) throw new TypeError(`Unknown property "${key}"`);
if (typeof value !== expected) {
throw new TypeError(`"${key}" must be a ${expected}, got ${typeof value}`);
}
return Reflect.set(target, key, value, receiver); // returns true on success
},
});
}
const person = typed({ name: "string", age: "number" });
person.name = "Ada";
// person.age = "36"; // TypeError: "age" must be a number, got string
// person.email = "a@b.c"; // TypeError: Unknown property "email"
Example: negative indices and defaults.
const pythonic = (arr) => new Proxy(arr, {
get(target, key, receiver) {
if (typeof key === "string" && /^-\d+$/.test(key)) { // property keys arrive as strings
key = String(target.length + Number(key)); // -1 -> length - 1
}
return Reflect.get(target, key, receiver);
},
});
console.log(pythonic(["a", "b", "c"])[-1]); // "c"
const withDefaults = (obj, fallback) => new Proxy(obj, {
get: (t, k, r) => (k in t ? Reflect.get(t, k, r) : fallback),
});
const labels = withDefaults({ save: "Save" }, "(missing label)");
console.log(labels.save, labels.cancel); // "Save" "(missing label)"
Example: reactivity. The core idea behind reactive frameworks fits in a few lines: intercept writes and notify subscribers.
function reactive(obj, onChange) {
return new Proxy(obj, {
set(target, key, value, receiver) {
const old = target[key];
const ok = Reflect.set(target, key, value, receiver);
if (ok && old !== value) onChange(key, old, value); // notify only on real changes
return ok;
},
});
}
const state = reactive({ count: 0 }, (k, o, n) => console.log(`${k}: ${o} -> ${n}`));
state.count++; // "count: 0 -> 1": the UI could re-render here
Limits and costs.
- Invariants. Traps cannot lie about the target in ways that break the language's guarantees. For example, a
gettrap must report the real value of a non-writable, non-configurable property, or the engine throws aTypeError. - Identity. A proxy is a different object from its target:
proxy !== target, and code that holds the target bypasses the traps. - Private fields and internal slots. Built-ins such as
Map,SetandDate, and classes with#privatefields, store data in internal slots that a proxy does not have. Callingproxy.method()runs withthisset to the proxy and throws aTypeError. The usual fix is agettrap that binds methods to the target. - Performance. Every intercepted operation runs a function, so proxies are much slower than plain objects in hot code.
- Revocation.
Proxy.revocable(target, handler)returns{ proxy, revoke }. Afterrevoke(), every operation on the proxy throws, which is useful for handing out access temporarily.
Key Takeaways
- A
Proxywraps a target and lets handler traps intercept fundamental operations such asget,set,has,deleteProperty,applyandconstruct. Reflectprovides the default behaviour of each operation. Forward withReflect.method(...args)and passreceiverso accessors keep the correctthis.- Typical uses include validation, default values, logging and debugging, access control and reactive state.
- A
settrap must returntrue, and traps must respect invariants of non-configurable properties. - Proxies do not have the target's internal slots or
#privatefields, are slower than plain objects, and are a different object from the target.
Practice
- (Beginner) Write a
logged(obj)proxy that logs every property read and write with its key and value, and use it to discover which properties a library function reads from an options object you pass to it.- (Intermediate) Write
readonlyDeep(obj), a proxy that throws on anyset,deletePropertyordefinePropertyat any depth by wrapping nested objects lazily in thegettrap. Compare it withdeepFreezefrom 4.1: which one protects the original object, and which only protects access through the wrapper?- (Interview) "Why does
new Proxy(new Map(), {}).get('a')throw, and how would you make a proxy around aMapwork?" Hint: consider whatthisis when thegetmethod runs, and where aMapactually stores its entries.
Memory Model and Garbage Collection
In JavaScript you never free memory yourself. You create objects, and the engine reclaims them when they are no longer needed. This is convenient, but it does not make memory problems impossible. Long-running single-page applications, servers and browser extensions regularly suffer from memory leaks: objects your program no longer needs but still references, so the collector cannot free them. It also helps to know how engines lay out objects in memory, because a few coding habits make objects significantly faster.
Stack and heap. Conceptually, a running program uses two memory regions. The call stack holds a frame for each active function call, with its local variables and arguments. Primitives can live there directly. The heap is a large pool where objects, arrays, functions and closures' captured variables live. Variables on the stack hold references into the heap. (Engines are free to optimise beyond this model, but it is the right mental picture.)
Call stack Heap
+---------------------+ +-------------------------------+
| render() | | |
| items: ref -------+-------->| [ {..}, {..}, {..} ] |
| count: 3 | | | |
+---------------------+ | +--> { id: 1, ... } |
| main() | | |
| config: ref ------+-------->| { theme: "dark" } |
+---------------------+ | |
frames are discarded | { old: "data" } <- unreachable: garbage
when functions return +-------------------------------+
Reachability. The garbage collector does not ask "will this object be used again?" (which is impossible to know in general). It asks "can the program still reach this object?". It starts from a set of roots: global variables, the variables of every function on the call stack, and active handles held by the host such as pending timers, registered event listeners and the DOM tree. Every object reachable from a root by following references is kept alive. Everything else is garbage and can be reclaimed.
Mark-and-sweep. Modern collectors are based on mark-and-sweep: mark every object reachable from the roots, then sweep (free) everything unmarked. Because reachability is what counts, circular references are not a problem. Two objects that reference each other but are unreachable from the roots are collected together. (Early reference-counting collectors, such as the one in old Internet Explorer for DOM objects, leaked on cycles.)
Generational collection. Most objects die young: temporary arrays, intermediate strings, callback closures. Engines such as V8 exploit this by splitting the heap into a young generation, collected very frequently with a fast copying "scavenger", and an old generation for objects that survived several collections, collected less often with an incremental and concurrent mark-sweep-compact. Most collection work runs in small steps or on background threads to avoid long pauses that would drop animation frames.
How engines store objects: hidden classes. A dictionary for every object would be slow. Instead, engines give each object a hidden class (called a "map" in V8 and a "shape" in SpiderMonkey) that describes its layout: which properties it has, in what order, at which offsets. Objects created the same way share a hidden class, and property access code caches the offset (an inline cache), making obj.x nearly as fast as a field access in C. Some habits keep objects on this fast path:
// Good: same properties, same order, set in one place (a constructor or literal)
class Point { constructor(x, y) { this.x = x; this.y = y; } }
const pts = [new Point(1, 2), new Point(3, 4)]; // all share one hidden class
// Slower: different insertion orders produce different hidden classes
const a = {}; a.x = 1; a.y = 2;
const b = {}; b.y = 2; b.x = 1; // different shape from a
// Slower: delete can move an object to the slow "dictionary mode"
const c = { x: 1, y: 2 };
delete c.x; // prefer c.x = undefined if the shape matters
These are micro-optimisations: they matter for hot code processing many objects, not for ordinary application logic.
Common leaks and their fixes.
| Leak | Why the memory stays reachable | Fix |
|---|---|---|
| Forgotten timers | setInterval callbacks are roots, and so is everything their closures capture | clearInterval when the owner goes away |
| Forgotten event listeners | The listener (and its closure) is referenced by the target, often window or document | removeEventListener, or { signal } with an AbortController |
| Detached DOM nodes | A removed element is still referenced from a JavaScript variable or cache | Drop references, or use a WeakMap keyed by the element |
| Unbounded caches | A Map or array that only grows | Size limits (LRU, 4.2), expiry, or WeakMap |
| Accidental globals | Assigning to an undeclared variable in sloppy mode creates a global | Strict mode (3.4) |
| Large captured scopes | A long-lived closure keeps alive the variables it captures (3.3) | Capture only what you need, and null out large data |
class LiveSearch {
#controller = new AbortController();
#results = [];
constructor(input) {
const { signal } = this.#controller;
input.addEventListener("input", (e) => this.#search(e.target.value), { signal });
window.addEventListener("resize", () => this.#layout(), { signal }); // window is a long-lived root
this.timer = setInterval(() => this.#refresh(), 30_000);
}
destroy() {
this.#controller.abort(); // removes BOTH listeners at once
clearInterval(this.timer); // the interval no longer references this object
this.#results = []; // release large data early
}
#search(q) {} #layout() {} #refresh() {}
}
// Without destroy(), the window listener and the interval keep every LiveSearch alive forever,
// together with its input element and results, even after the component is removed from the page.
Weak references. WeakMap and WeakSet (4.2) hold their keys without keeping them alive. For rarer cases, ES2021 added WeakRef (a reference that does not prevent collection; deref() returns the object or undefined) and FinalizationRegistry (a callback that may run some time after an object is collected). Collection timing is unpredictable, so never use them for program logic that must run, only for optional cleanup and caches.
Finding leaks. In Chrome DevTools, the Memory panel can take heap snapshots. Take one, perform an action several times (open and close a dialog), take another, and compare: objects whose count keeps growing are leaking. The retainers view shows the chain of references from a root that keeps an object alive, which tells you which reference to break. The Performance panel's memory graph shows whether the heap returns to its baseline after garbage collection.
Key Takeaways
- Objects live on the heap and are referenced from the stack, globals and host structures. Memory is reclaimed automatically when an object becomes unreachable from the roots.
- Mark-and-sweep collectors handle circular references. Generational collection makes short-lived objects cheap.
- A leak is memory that is still reachable but no longer needed: forgotten timers and listeners, detached DOM nodes, unbounded caches and long-lived closures.
- Always provide a teardown path (
clearInterval,AbortControllerfor listeners) for long-lived resources. - Engines optimise objects that share a hidden class: initialise the same properties in the same order and avoid
deletein hot code.
Practice
- (Beginner) Write a page with a button that creates 10,000 objects and stores them in a global array, and another button that clears the array. Watch the JavaScript heap size in DevTools' Performance monitor while clicking both, and force a garbage collection to see the memory return.
- (Intermediate) Build a small modal component that adds a
keydownlistener todocumentand asetIntervalwhen opened. Open and close it 50 times without cleanup, take heap snapshots before and after, and find the leaked instances in the comparison view. Then fix the leak with anAbortControllerandclearInterval, and verify with a new snapshot.- (Interview) "JavaScript has a garbage collector, so how can a JavaScript application leak memory?" Hint: distinguish "unreachable" from "no longer needed", and give examples involving roots that the browser holds on your behalf.
<a id="5-asynchronous-programming-in-javascript"></a>