4. Objects, prototypes and classes
Sections
Reading settings
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
Code: the files examples/l04_*.js, errors/l04_private_outside.js, and the C# and Java sides in compare/l04_equality.cs and compare/L04Equality.java.
Objects are property bags
Section titled “Objects are property bags”import { show } from './show.js';
const page = { title: 'Mission', order: 0 };page.locale = 'en'; // adddelete page.order; // removeshow('page', page);show('page.author', page.author);show("'title' in page", 'title' in page);show("Object.hasOwn(page, 'title')", Object.hasOwn(page, 'title'));
// Property names are strings (or symbols): other keys are convertedconst grid = {};grid[1] = 'one';grid['1'] = 'one, again';grid[{ x: 1 }] = 'an object';grid[{ y: 2 }] = 'another object';show('grid', grid);
// Order: integer-like keys first, in ascending order, then the others in insertion orderconst lessons = { journal: 99, 10: 'ten', index: 0, 2: 'two' };show('Object.keys(lessons)', Object.keys(lessons));
// Shorthand properties, computed names and methodsconst field = 'draft';const title = 'Values and types';const lesson = { title, [field]: true, describe() { return `${this.title} (${this.draft ? 'draft' : 'published'})`; },};show('lesson.describe()', lesson.describe());show('Object.entries(lesson)', Object.entries(lesson));page { title: 'Mission', locale: 'en' }page.author undefined'title' in page trueObject.hasOwn(page, 'title') truegrid { '1': 'one, again', '[object Object]': 'another object' }Object.keys(lessons) [ '2', '10', 'journal', 'index' ]lesson.describe() 'Values and types (draft)'Object.entries(lesson) [ [ 'title', 'Values and types' ], [ 'draft', true ], [ 'describe', [Function: describe] ]]A C# or Java object has the fields its class declares, no more, no less. A JavaScript object is closer to a Dictionary<string, object>, or to C#’s ExpandoObject: properties are added by assigning them, removed with delete, and a missing one reads as undefined. The literal { … } needs no class, and is how most JavaScript code passes data around.
Two details of that dictionary surprise:
- Keys are strings (or symbols).
grid[1]andgrid['1']are the same property, and every object used as a key becomes the same string,'[object Object]'. Use aMapwhen keys aren’t strings; lesson 5 compares it withDictionaryandHashMap. - The order isn’t only insertion order. Keys that look like array indexes come first, in ascending numeric order, then the other strings in the order they were added (OrdinaryOwnPropertyKeys). An object keyed by year or by identifier gets sorted.
The prototype chain
Section titled “The prototype chain”import { show } from './show.js';
const base = { kind: 'page', describe() { return `${this.title} is a ${this.kind}`; },};const mission = Object.create(base); // mission's prototype is basemission.title = 'Mission';
show('mission.describe()', mission.describe());show('Object.keys(mission)', Object.keys(mission));show("Object.hasOwn(mission, 'kind')", Object.hasOwn(mission, 'kind'));const { getPrototypeOf } = Object;show('getPrototypeOf(mission) === base', getPrototypeOf(mission) === base);
// Reading walks up the chain; writing creates an own property that hides the prototype'smission.kind = 'lesson';show('mission.describe()', mission.describe());show('base.kind', base.kind);
// A change to the prototype is seen by every object that inherits from it, even existing onesconst journal = Object.create(base);journal.title = 'Journal';base.describe = function () { return `${this.title}, ${this.kind}, changed at run time`;};show('journal.describe()', journal.describe());
// The end of the chainshow('getPrototypeOf(base)', getPrototypeOf(base));show('getPrototypeOf(Object.prototype)', getPrototypeOf(Object.prototype));const dictionary = Object.create(null);show("'toString' in {}", 'toString' in {});show("'toString' in dictionary", 'toString' in dictionary);mission.describe() 'Mission is a page'Object.keys(mission) [ 'title' ]Object.hasOwn(mission, 'kind') falsegetPrototypeOf(mission) === base truemission.describe() 'Mission is a lesson'base.kind 'page'journal.describe() 'Journal, page, changed at run time'getPrototypeOf(base) [Object: null prototype] {}getPrototypeOf(Object.prototype) null'toString' in {} true'toString' in dictionary falseEvery object has a hidden link to another object, its prototype, or to null. Reading a property that the object doesn’t have goes on to the prototype, then to the prototype’s prototype, until it finds the property or reaches null (OrdinaryGet). mission has one own property, title, and finds kind and describe on base. When describe runs, this is still mission, by the implicit rule of lesson 3, so the method reads mission’s title.
That lookup is JavaScript’s inheritance, and it differs from a class hierarchy in three ways:
- It links objects, not types. Any object can serve as the prototype of another, with
Object.create. - Writing doesn’t go up the chain.
mission.kind = 'lesson'creates an own property that hides the prototype’s, andbase.kindis unchanged. - It is live. Replacing
base.describechanges the method of every object that inherits frombase, including objects created before the change. That is how old libraries added methods to built-in types, a practice now discouraged.
Plain objects inherit from Object.prototype, where toString and hasOwnProperty live; in sees them, Object.hasOwn doesn’t. Object.create(null) makes an object with no prototype at all, a dictionary with no inherited keys.
Classes
Section titled “Classes”import { attempt, show } from './show.js';
class Lesson { static count = 0; title; // public field: an own property of each instance #minutes = 0; // private field: unreachable outside the class body
constructor(title, minutes) { this.title = title; this.minutes = minutes; // calls the setter Lesson.count++; }
get minutes() { return this.#minutes; }
set minutes(value) { if (!Number.isInteger(value) || value < 0) { throw new RangeError(`minutes must be a non-negative integer, got ${value}`); } this.#minutes = value; }
describe() { return `${this.title} (${this.#minutes} min)`; }
static isLesson(value) { return #minutes in value; // does value have this class's private field? }}
class Exercise extends Lesson { constructor(title, minutes, solution) { super(title, minutes); this.solution = solution; }
describe() { return `${super.describe()}, with a solution`; }}
const values = new Lesson('Values and types', 25);const quiz = new Exercise('Coercions', 10, 'details');show('values.describe()', values.describe());show('quiz.describe()', quiz.describe());show('values', values);show('Object.keys(quiz)', Object.keys(quiz));show('Lesson.count', Lesson.count);attempt("values.minutes = 'ten'", () => { values.minutes = 'ten';});show('values.minutes', values.minutes);
// Underneath: a function, and methods on its prototypeshow('typeof Lesson', typeof Lesson);show("Object.hasOwn(values, 'describe')", Object.hasOwn(values, 'describe'));const proto = Lesson.prototype;show('values.describe === proto.describe', values.describe === proto.describe);show('quiz.describe === proto.describe', quiz.describe === proto.describe);show('quiz instanceof Lesson', quiz instanceof Lesson);attempt("Lesson('no new')", () => Lesson('no new'));
// Private fields are checked by the engine, not hidden by conventionshow('Lesson.isLesson(quiz)', Lesson.isLesson(quiz));show('Lesson.isLesson({ minutes: 5 })', Lesson.isLesson({ minutes: 5 }));show("Object.hasOwn(values, '#minutes')", Object.hasOwn(values, '#minutes'));show('JSON.stringify(values)', JSON.stringify(values));attempt('proto.describe.call({ title })', () => proto.describe.call({ title: 'fake' }));values.describe() 'Values and types (25 min)'quiz.describe() 'Coercions (10 min), with a solution'values Lesson { title: 'Values and types' }Object.keys(quiz) [ 'title', 'solution' ]Lesson.count 2values.minutes = 'ten' RangeError: minutes must be a non-negative integer, got tenvalues.minutes 25typeof Lesson 'function'Object.hasOwn(values, 'describe') falsevalues.describe === proto.describe truequiz.describe === proto.describe falsequiz instanceof Lesson trueLesson('no new') TypeError: Class constructor Lesson cannot be invoked without 'new'Lesson.isLesson(quiz) trueLesson.isLesson({ minutes: 5 }) falseObject.hasOwn(values, '#minutes') falseJSON.stringify(values) '{"title":"Values and types"}'proto.describe.call({ title }) TypeError: Cannot read private member #minutes from an object whose class did not declare itThe syntax reads like C# or Java, and most of it means the same thing:
| C# | Java | JavaScript | |
|---|---|---|---|
| Field | public string Title; |
public String title; |
title;, an own property of each instance |
| Private field | private int minutes; |
private int minutes; |
#minutes, checked by the engine |
| Property | public int Minutes { get; set; } |
getMinutes(), setMinutes() |
get minutes(), set minutes(value) |
| Static member | static int Count; |
static int count; |
static count = 0; |
| Inheritance | class Exercise : Lesson |
class Exercise extends Lesson |
class Exercise extends Lesson, one parent |
| Call the parent | base.Describe() |
super.describe() |
super.describe() |
| Several constructors | yes | yes | one constructor |
| Interfaces, abstract classes | yes | yes | no; an object fits if it has the methods called |
Underneath, class is a layer over the prototypes of the previous section (ClassDefinitionEvaluation). Lesson is a function, the constructor. Methods such as describe are stored once on Lesson.prototype, and every instance finds them through its prototype link, which is why values has no own describe. extends links Exercise.prototype to Lesson.prototype, so the chain of quiz is:
flowchart LR quiz["quiz<br/>own: title, solution, #minutes"] -- prototype --> ep["Exercise.prototype<br/>constructor, describe"] ep -- prototype --> lp["Lesson.prototype<br/>constructor, describe,<br/>get minutes, set minutes"] lp -- prototype --> op["Object.prototype<br/>toString, hasOwnProperty…"] op -- prototype --> n["null"]quiz.describe finds Exercise.prototype.describe first, and super.describe() continues one link up. instanceof walks the same chain, looking for Lesson.prototype. What a class adds over a hand-written prototype is mostly safety: calling it without new throws, its body is strict, and its methods aren’t enumerable, so a for…in loop over an instance doesn’t list them.
Private fields are the one feature with no prototype equivalent. A name that starts with # exists only inside the class body; code outside can’t even mention it, and the module is rejected before it runs:
class Lesson { #minutes = 25;}console.log('this line never runs');console.log(new Lesson().#minutes);errors/l04_private_outside.js:6console.log(new Lesson().#minutes); ^
SyntaxError: Private field '#minutes' must be declared in an enclosing class
Node.js v24.21.0Inside the class, reading #minutes on an object that doesn’t have it throws a TypeError, as the last line of the output shows, and #minutes in value tests for it without throwing. Private fields aren’t properties: Object.hasOwn, Object.keys and JSON.stringify don’t see them. C#’s reflection can read a private field and Java’s can with setAccessible; nothing in JavaScript can read a # field from outside. TypeScript’s private keyword, by contrast, is checked only at compile time, and the next course shows what remains of it at run time.
Accessors run code on a property read or write, like C# properties: this.minutes = minutes in the constructor goes through the setter and its validation. And the fields of a class are created on each instance, which is why an arrow function in a field, as in the listener pattern of lesson 3, costs one function per object.
Equality and copies
Section titled “Equality and copies”import { attempt, show } from './show.js';
const a = { x: 1, y: 2 };const b = { x: 1, y: 2 };show('a === b', a === b);show('a === a', a === a);
// Map and Set use the same identity: two equal-looking keys are two keysconst visits = new Map();visits.set({ x: 1, y: 2 }, 'first');visits.set({ x: 1, y: 2 }, 'second');show('visits.size', visits.size);show('visits.get({ x: 1, y: 2 })', visits.get({ x: 1, y: 2 }));const byKey = new Map([[`${a.x},${a.y}`, 'first']]);show("byKey.get('1,2')", byKey.get('1,2'));
// Spread and Object.assign copy one levelconst course = { title: 'JavaScript', tags: ['node'], created: new Date(Date.UTC(2026, 8, 14)) };const shallow = { ...course };shallow.title = 'TypeScript';shallow.tags.push('typescript');show('course.title', course.title);show('course.tags', course.tags);
// structuredClone copies the whole graph: Map, Set, Date, cyclesconst deep = structuredClone(course);deep.tags.push('react');show('course.tags', course.tags);show('deep.created instanceof Date', deep.created instanceof Date);
// but not functions, and not the prototype of a class instanceclass Point { constructor(x, y) { this.x = x; this.y = y; } length() { return Math.hypot(this.x, this.y); }}const cloned = structuredClone(new Point(3, 4));show('cloned', cloned);show('cloned instanceof Point', cloned instanceof Point);attempt('structuredClone({ f() {} })', () => structuredClone({ f() {} }));
// JSON.parse(JSON.stringify(...)) loses moreconst viaJson = JSON.parse(JSON.stringify({ ...course, draft: undefined }));show('viaJson', viaJson);
// Object.freeze is shallow tooconst frozen = Object.freeze({ tags: ['node'] });frozen.tags.push('still mutable');show('frozen.tags', frozen.tags);a === b falsea === a truevisits.size 2visits.get({ x: 1, y: 2 }) undefinedbyKey.get('1,2') 'first'course.title 'JavaScript'course.tags [ 'node', 'typescript' ]course.tags [ 'node', 'typescript' ]deep.created instanceof Date truecloned { x: 3, y: 4 }cloned instanceof Point falsestructuredClone({ f() {} }) DataCloneError: f() {} could not be cloned.viaJson { title: 'JavaScript', tags: [ 'node', 'typescript' ], created: '2026-09-14T00:00:00.000Z'}frozen.tags [ 'node', 'still mutable' ]C# records and Java records compare by value, and a Dictionary or a HashMap uses that equality for its keys:
> dotnet run l04_equality.csa == b (record): TrueReferenceEquals(a, b): Falsevisits.Count: 1c == d (class): Falsea with { Y = 5 }: Point { X = 1, Y = 5 }> java L04Equality.javaa == b: falsea.equals(b): truevisits.size(): 1JavaScript has no records, no Equals or equals to override, and no operator overloading. === on two objects asks whether they are the same object, like ReferenceEquals, and Map and Set use the same identity (SameValueZero): two points with the same coordinates are two keys, and a new { x: 1, y: 2 } finds neither. To key a map by value, build a string or a number from the values, as byKey does. Comparing two objects field by field is exercise 1.
Copies need the same care, because nothing copies implicitly:
- Spread
{ ...course }andObject.assigncopy the own properties, one level deep:shallow.tagsis the same array ascourse.tags. That is what C#’swithdoes too, on a record. structuredClone, defined by the HTML standard and available in Node.js, copies the whole graph, including dates, maps, sets and cycles. It refuses functions, with aDataCloneError, and it doesn’t keep prototypes: the clone of aPointis a plain object with nolengthmethod.JSON.parse(JSON.stringify(…)), the old trick, loses more:undefinedproperties disappear, dates become strings, and abigintthrows.Object.freezeis shallow for the same reason: it freezes one object, not the ones it points to.
In GuitarAlchemist/ga: merging saved preferences
Section titled “In GuitarAlchemist/ga: merging saved preferences”SceneOptions.tsx builds the options of a 3D scene: default values, then parameters of the URL such as ?tower that turn options on (lines 62-73), then the preferences saved in localStorage, merged with one line (line 77):
if (saved) Object.assign(state, JSON.parse(saved));Each toggle saves the whole state (line 94). Reduced to three options, in plain JavaScript:
import { show } from './show.js';
function getDefaults(search, saved) { const state = { stars: true, tower: false, skyboxMode: 'milky-way' }; // Lines 62-73: URL parameters override the defaults const params = new URLSearchParams(search); if (params.has('tower')) state.tower = true; // Lines 75-78: then the preferences saved in localStorage are merged in if (saved) Object.assign(state, JSON.parse(saved)); return state;}
// Every toggle saves the whole state (lines 94, 103 and 112), so a saved state has every keyconst saved = JSON.stringify({ stars: false, tower: false, skyboxMode: 'milky-way' });show("getDefaults('?tower', null)", getDefaults('?tower', null));show("getDefaults('?tower', saved)", getDefaults('?tower', saved));
// Object.assign copies every own property of the source, known or notshow('with an old key', getDefaults('', '{"bloomLevel":3,"stars":"yes"}'));
// JSON.parse makes __proto__ an ordinary property; Object.assign then assigns it, which changes the prototypeconst parsed = JSON.parse('{"__proto__":{"isAdmin":true}}');show("Object.hasOwn(parsed, '__proto__')", Object.hasOwn(parsed, '__proto__'));const state = getDefaults('', '{"__proto__":{"isAdmin":true}}');show('state.isAdmin', state.isAdmin);show("Object.hasOwn(state, 'isAdmin')", Object.hasOwn(state, 'isAdmin'));show('{}.isAdmin', {}.isAdmin);
// Spread defines properties instead of assigning them: the prototype stays Object.prototypeconst spread = { ...parsed };show('spread.isAdmin', spread.isAdmin);show('Object.keys(spread)', Object.keys(spread));getDefaults('?tower', null) { stars: true, tower: true, skyboxMode: 'milky-way' }getDefaults('?tower', saved) { stars: false, tower: false, skyboxMode: 'milky-way' }with an old key { stars: 'yes', tower: false, skyboxMode: 'milky-way', bloomLevel: 3 }Object.hasOwn(parsed, '__proto__') truestate.isAdmin trueObject.hasOwn(state, 'isAdmin') false{}.isAdmin undefinedspread.isAdmin undefinedObject.keys(spread) [ '__proto__' ]Three things happen, from the most visible to the most obscure:
- The URL loses to the saved state.
Object.assigncopies its sources in order, so the last one wins, and the saved state comes last. As soon as a user has toggled one option, the saved state contains every key, and?towerno longer turns the tower on. The comment above the URL block calls those parameters “overrides”; merging them after the saved preferences would make them override. - Whatever is saved is copied, known or not. An option renamed in a later version stays in
localStorageand comes back asbloomLevel, and a value of the wrong type,stars: 'yes', replaces a boolean. TypeScript’sSceneOptionsStatetype doesn’t check whatJSON.parsereturns, since the types are gone at run time. - A
__proto__key changes the prototype.JSON.parsecreates an ordinary own property named__proto__.Object.assignthen assigns it to the target, and assigning__proto__calls theObject.prototype.__proto__setter, which replaces the target’s prototype:state.isAdministruewithout being an own property. Only that object is affected,{}.isAdminstaysundefined, and in GA the data comes from the user’s own browser, so this is a curiosity rather than a hole. The same pattern applied to data from a request is a known vulnerability class, prototype pollution. Spread doesn’t assign, it defines properties, and keeps__proto__as a plain key.
Exercise 3 rewrites the function.
Key takeaways
Section titled “Key takeaways”- An object is a set of string keys that can change at run time; a missing property reads as
undefined; integer-like keys are listed first. - Reading a property walks the prototype chain; writing creates an own property. Prototypes link objects, and changes to them are live.
classbuilds a constructor function and a prototype: methods are shared on the prototype, fields are created on each instance.#privatefields are enforced by the engine, and invisible toObject.keysandJSON.stringify.===,MapandSetcompare objects by identity; there are no records and noEqualsto override.- Spread and
Object.assigncopy one level;structuredClonecopies deeply but drops prototypes and refuses functions. Object.assignlets the last source win and copies every key,__proto__included.
Exercises
Section titled “Exercises”- Write
deepEqual(a, b), true when two values are identical, or are objects with the same prototype and the same own keys holding deeply equal values. What does it answer for two different dates, and why?
Solution
solutions/l04_ex1_deep_equal.js:
function deepEqual(a, b) { if (Object.is(a, b)) return true; if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false; const keys = Object.keys(a); if (keys.length !== Object.keys(b).length) return false; return keys.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]));}
console.log(deepEqual({ x: 1, tags: ['a'] }, { tags: ['a'], x: 1 }));console.log(deepEqual({ x: 1 }, { x: 1, y: undefined }));console.log(deepEqual([1, 2], { 0: 1, 1: 2 }));console.log(deepEqual(NaN, NaN), deepEqual(0, -0));console.log(deepEqual(new Date(0), new Date(1)));truefalsefalsetrue falsetrueObject.is handles the primitives, and says NaN equals NaN but 0 differs from -0. The prototype check tells an array from an object with the same keys. The last line is wrong on purpose: a date keeps its time in an internal slot, not in a property, so two dates have no own keys and look equal. A general deep equality must know each built-in type; Node.js’s assert.deepStrictEqual does, and lesson 9 uses it in tests.
- Write a class
Temperaturethat stores degrees Celsius in a private field, exposes afahrenheitgetter and setter, has a static factoryfromFahrenheit, and serializes to{"celsius": …}withJSON.stringify.
Solution
solutions/l04_ex2_temperature.js:
class Temperature { #celsius;
constructor(celsius) { this.#celsius = celsius; }
static fromFahrenheit(fahrenheit) { return new Temperature(((fahrenheit - 32) * 5) / 9); }
get fahrenheit() { return (this.#celsius * 9) / 5 + 32; }
set fahrenheit(value) { this.#celsius = ((value - 32) * 5) / 9; }
toJSON() { return { celsius: this.#celsius }; }}
const room = new Temperature(20);console.log(room.fahrenheit);room.fahrenheit = 212;console.log(JSON.stringify(room));console.log(JSON.stringify(Temperature.fromFahrenheit(32)));console.log(Object.keys(room), room);68{"celsius":100}{"celsius":0}[] Temperature {}Without toJSON, JSON.stringify(room) would print {}, since a private field isn’t a property; toJSON plays the role of a custom converter in System.Text.Json or Jackson. The last line shows the same invisibility: no keys, and Node.js’s display shows an empty Temperature.
- Rewrite
getDefaultsfrom the GA example so that the URL parameters win over the saved preferences, saved keys that aren’t options are ignored, a saved value replaces a default only if it has the same type, and__proto__can’t change anything.
Solution
function getDefaults(search, saved) { const state = { stars: true, tower: false, skyboxMode: 'milky-way' }; const preferences = saved ? JSON.parse(saved) : {}; for (const key of Object.keys(state)) { // Object.keys(state) lists the known keys: __proto__ and old keys are never read if (Object.hasOwn(preferences, key) && typeof preferences[key] === typeof state[key]) { state[key] = preferences[key]; } } const params = new URLSearchParams(search); if (params.has('tower')) state.tower = true; // last, so the URL wins return state;}
const saved = JSON.stringify({ stars: false, tower: false, skyboxMode: 'milky-way' });console.log(getDefaults('?tower', saved));console.log(getDefaults('', '{"bloomLevel":3,"stars":"yes","__proto__":{"isAdmin":true}}'));console.log(getDefaults('', '{"__proto__":{"isAdmin":true}}').isAdmin);{ stars: false, tower: true, skyboxMode: 'milky-way' }{ stars: true, tower: false, skyboxMode: 'milky-way' }undefinedThe loop turns the question around: instead of copying whatever the saved object contains, it asks the saved object for each key the state knows. The typeof test is a minimal validation; a value such as skyboxMode would also need to be one of the allowed strings, which is where a schema library, or the static types and runtime checks of the TypeScript course, come in.
Sources
Section titled “Sources”- ECMAScript — Ordinary object internal methods, OrdinaryOwnPropertyKeys, ClassDefinitionEvaluation, Object.assign, CopyDataProperties, Object.prototype.__proto__
- MDN — Inheritance and the prototype chain, Classes, Private elements, structuredClone
- HTML Standard — Structured serialize and deserialize
- Microsoft — Records, Java — Record classes