JS Classes, Prototypes and Modules, Run in jsc
JavaScript lecture 4 in jsc: prototype chain, class vs function, #private fields, the lost-this TypeError, circular imports, 40 vs 249 bytes per object.
Series: JavaScript lecture 4/6 (intermediate). For people who can write functions and use arrays and objects, and now meet class, extends, #private and import in real code without knowing what happens underneath. After this lecture you can predict where a property is found, choose between a class and a plain object, avoid the lost-this crash, and split code into ES modules without circular-import surprises.
If you came from Java, C# or Swift, JavaScript classes look familiar and that is the trap: underneath there are no class blueprints, only objects linked to other objects. If you came from plain scripts, class code in a framework looks like magic. This lecture removes the magic by running every piece on a real engine, printing the real output, and showing the exact error messages you will hit.
Environment: JavaScriptCore as shipped with macOS 26.6.2 (framework version 21624.5.1.11.3), run through its jsc shell on an Apple Silicon Mac (M1 Pro). Node.js, Deno and browsers were not run; where they behave differently we say so. If you did lecture 1 you already have the alias:
$ alias jsc=/System/Library/Frameworks/JavaScriptCore.framework/Versions/Current/Helpers/jsc
jsc prints with print(); there is no console in this shell.
The prototype chain: where a property is really found
Every object has a hidden link called [[Prototype]] to another object (or to null). When you read obj.name and obj does not have name itself, the engine follows that link and tries again, until it finds the name or reaches null. That is the whole inheritance model. Object.create(p) makes a new object whose prototype is p:
// proto.js — the prototype chain, step by step
const animal = { legs: 4, describe() { return `${this.name} has ${this.legs} legs`; } };
const cat = Object.create(animal); // cat's prototype is animal
cat.name = "Tama";
print(cat.describe()); // found on animal, this = cat
print(Object.hasOwn(cat, "legs"), "legs" in cat);
print(Object.getPrototypeOf(cat) === animal);
print(Object.getPrototypeOf(animal) === Object.prototype);
print(Object.getPrototypeOf(Object.prototype));
cat.legs = 3; // shadows, does not change animal
print(cat.legs, animal.legs);
const bird = Object.create(animal); bird.name = "Piyo";
animal.legs = 2; // every object without its own legs sees this
print(bird.describe());
for (const k in cat) print("for-in:", k);
print(Object.keys(cat));
$ jsc proto.js
Tama has 4 legs
false true
true
true
null
3 4
Piyo has 2 legs
for-in: name
for-in: legs
for-in: describe
name,legs
Three things to take from this output:
- Own vs inherited.
Object.hasOwn(cat, "legs")wasfalsewhile"legs" in catwastrue:inwalks the chain,hasOwn(ES2022) does not. - Writing never walks the chain.
cat.legs = 3created a new own property oncat(this is called shadowing);animal.legsstayed 4. But changinganimal.legslater changed whatbirdsees, becausebirdhas no ownlegs. Shared, mutable data on a prototype is a classic bug source. for...inincludes inherited enumerable keys (describeshowed up),Object.keysdoes not. UseObject.keys/Object.entriesfor data.
this inside describe was cat, not animal: this is the object the method was called on, wherever the method was found.
class is a function, with four differences we could observe
Before ES2015, "classes" were constructor functions plus methods on .prototype. The class keyword produces the same shape, which is why both versions below return 5:
function PointFn(x, y) { this.x = x; this.y = y; }
PointFn.prototype.len = function () { return Math.hypot(this.x, this.y); };
class Point { constructor(x, y) { this.x = x; this.y = y; } len() { return Math.hypot(this.x, this.y); } }
print(typeof Point, typeof PointFn);
print(new PointFn(3, 4).len(), new Point(3, 4).len());
for (const k in new PointFn(3, 4)) print("fn for-in:", k);
for (const k in new Point(3, 4)) print("cls for-in:", k);
print(Object.getOwnPropertyDescriptor(Point.prototype, "len").enumerable,
Object.getOwnPropertyDescriptor(PointFn.prototype, "len").enumerable);
const p = PointFn(1, 2); // forgot new (sloppy mode)
print(p, typeof globalThis.x, globalThis.x);
try { Point(1, 2); } catch (e) { print(e); }
$ jsc classvsfn.js
function function
5 5
fn for-in: x
fn for-in: y
fn for-in: len
cls for-in: x
cls for-in: y
false true
undefined number 1
TypeError: Cannot call a class constructor Point without |new|
The differences, each visible above or in a later section:
- Forgetting
new. With the old function,PointFn(1, 2)returnedundefinedand silently wrotexandyonto the global object (globalThis.xbecame 1). A class refuses with aTypeError. That alone is a good reason to preferclass. - Methods are not enumerable.
lenleaked intofor...infor the function version, not for the class. - The class body is strict mode. You will see what that means for
thisin the lost-thissection. Point.prototypeis read-only. Shown in theinstanceofsection.
Private #fields, getters and static blocks
ES2022 added real privacy: names starting with # exist only inside the class body. Here is a small bank account using every class feature from ES2022 at once:
class Account {
#balance = 0; // private field (ES2022)
static #count = 0; // private static
static { print("static block ran"); } // static initialization block (ES2022)
constructor(owner) { this.owner = owner; Account.#count++; }
deposit(n) { this.#check(n); this.#balance += n; return this; }
#check(n) { if (!(n > 0)) throw new RangeError(`bad amount: ${n}`); }
get balance() { return this.#balance; } // read-only from outside
static isAccount(o) { return #balance in o; } // brand check (ES2022)
static get count() { return Account.#count; }
}
const a = new Account("kim").deposit(100).deposit(50);
print(a.balance, Account.count);
a.balance = 999; // no setter: silently ignored in sloppy mode
print(a.balance);
print(Object.keys(a), JSON.stringify(a));
print(Account.isAccount(a), Account.isAccount({ balance: 1 }));
try { a.deposit(-5); } catch (e) { print(e); }
try { Account.prototype.deposit.call({}, 10); } catch (e) { print(e); }
$ jsc account.js
static block ran
150 1
150
owner {"owner":"kim"}
true false
RangeError: bad amount: -5
TypeError: Cannot access private method or acessor (evaluating 'this.#check')
Read the output line by line:
a.balance = 999did nothing and said nothing, because there is a getter but no setter and the assignment was in sloppy (non-strict) script code. In strict code the same assignment throws:jsc -e '"use strict"; ...'printedTypeError: Attempted to assign to readonly property.Bugs like this hide in old non-module scripts.- Private fields are invisible to
Object.keysandJSON.stringify. If you serialize a class with private state, write atoJSON()method. #balance in ochecks whether an object was really built by this class, which duck typing ("balance" in o) cannot tell.- Calling a method on a foreign object fails on the first private access. Note that JavaScriptCore's message really says "acessor" (sic). Search for it exactly as printed if you hit it.
Accessing a private name from outside is not even a runtime error. It is a SyntaxError found while parsing, so not one line of the file runs:
$ cat outside.js
print("first line runs?");
class A { #x = 1 }
const a = new A();
print(a.#x);
$ jsc outside.js
Exception: SyntaxError: Cannot reference undeclared private names: "#x"
at outside.js:5
$ echo $?
3
"first line runs?" never printed. (jsc reported line 5, one past the last line; the error is about the whole file.) And a["#x"] printed undefined: a string key "#x" is a completely different, ordinary property.
Common mistake 1: the setter that calls itself
What people write: a setter that trims the input and stores it under the same name.
class User {
set name(v) { this.name = v.trim(); } // BUG: calls itself
}
try { new User().name = " lee "; } catch (e) { print(e); }
What actually happens: this.name = ... inside the setter calls the setter again, forever.
$ jsc setter_loop.js
RangeError: Maximum call stack size exceeded.
Fix: store the value somewhere else, ideally a private field: #name; set name(v) { this.#name = v.trim(); } get name() { return this.#name; }.
Common mistake 2: the method that lost its this
What people write: pass a method as a callback (to setTimeout, an event listener, array.map) or copy it into a variable.
class Counter {
count = 0;
inc() { this.count++; return this.count; }
incArrow = () => { this.count++; return this.count; };
}
const c = new Counter();
const f = c.inc;
try { f(); } catch (e) { print(e); }
setTimeout(c.inc, 0); // same bug, later
const g = c.incArrow; print(g(), g());
const h = c.inc.bind(c); print(h());
print(Object.keys(c));
What actually happens:
$ jsc lostthis.js
TypeError: undefined is not an object (evaluating 'this.count')
1 2
3
count,incArrow
Exception: TypeError: undefined is not an object (evaluating 'this.count')
inc@lostthis.js:3:15
$ echo $?
3
this is set by the call, not by where the function was defined. f() has no object in front of the dot, and because class bodies are strict, this is undefined instead of the global object. The setTimeout version crashes later, uncaught, and ends the program with exit status 3. Chrome and Node print a differently worded message (Cannot read properties of undefined (reading 'count')); we did not run them, so search for both wordings.
Fix: c.inc.bind(c), a wrapper () => c.inc(), or an arrow-function class field. Note the last line: incArrow is an own property of every instance (Object.keys lists it), while inc lives once on the prototype. That has a cost, which we measured next.
Measured: what an arrow-function field costs per object
We built 100,000 live instances of the same tiny class, written four ways, and read the heap size after a full garbage collection. fullGC() and gcHeapSize() are jsc-only helper functions; in Node you would use --expose-gc and process.memoryUsage() (not run here).
// mem.js — heap cost per instance: prototype method vs per-instance function
// usage: jsc mem.js -- <variant> (run each variant in a fresh process)
class ProtoMethod { constructor(i) { this.i = i; } get() { return this.i; } }
class ArrowField { get = () => this.i; constructor(i) { this.i = i; } }
class BoundInCtor { constructor(i) { this.i = i; this.get = this.get.bind(this); } get() { return this.i; } }
class PrivateField { #i; constructor(i) { this.#i = i; } get() { return this.#i; } }
const variants = { ProtoMethod, ArrowField, BoundInCtor, PrivateField };
const N = 100_000;
const name = arguments[0];
const C = variants[name];
fullGC(); const before = gcHeapSize();
const t0 = performance.now();
const keep = new Array(N);
for (let i = 0; i < N; i++) keep[i] = new C(i);
const t1 = performance.now();
fullGC(); const after = gcHeapSize();
let sum = 0; for (const o of keep) sum += o.get(); // all instances still alive and usable
print(`${name.padEnd(12)} bytes/instance=${((after - before) / N).toFixed(1)} create=${(t1 - t0).toFixed(1)}ms sum=${sum}`);
Five rounds, each variant in its own fresh process (first round shown; the byte counts were identical in all five):
$ for v in ProtoMethod ArrowField BoundInCtor PrivateField; do jsc mem.js -- $v; done
ProtoMethod bytes/instance=40.4 create=2.4ms sum=4999950000
ArrowField bytes/instance=249.4 create=5.7ms sum=4999950000
BoundInCtor bytes/instance=136.4 create=5.4ms sum=4999950000
PrivateField bytes/instance=72.4 create=2.2ms sum=4999950000
| Style | Bytes per instance | Create 100,000 (median of 5) |
|---|---|---|
| Method on the prototype | 40.4 | 1.8 ms |
Private #i + prototype method |
72.4 | 2.2 ms |
bind() in the constructor |
136.4 | 5.4 ms |
| Arrow-function class field | 249.4 | 5.7 ms |
The numbers include the 8-byte slot in the keep array. An arrow field allocates a new function object (and its closure) for every instance: about six times the memory of a prototype method here, and three times slower to create. For 50 UI components that is noise; for 1,000,000 data records it is roughly 200 MB. Use arrow fields for objects whose methods are passed as callbacks (event handlers), and prototype methods for data-like objects. Private fields cost about 32 bytes more per object in JavaScriptCore; we did not dig into why, and V8 may differ.
extends, super and instanceof
class Shape {
constructor(name) { this.name = name; }
area() { return 0; }
toString() { return `${this.name}(${this.area().toFixed(2)})`; }
}
class Circle extends Shape {
constructor(r) { super("circle"); this.r = r; }
area() { return Math.PI * this.r ** 2; }
}
class Square extends Shape {
constructor(s) { super("square"); this.s = s; }
area() { return this.s ** 2; }
toString() { return "[" + super.toString() + "]"; }
}
const shapes = [new Circle(1), new Square(2)];
print(shapes.join(" "));
const c = shapes[0];
print(c instanceof Circle, c instanceof Shape, c instanceof Object, c instanceof Square);
print(Object.getPrototypeOf(Circle.prototype) === Shape.prototype, Object.getPrototypeOf(Circle) === Shape);
class Broken extends Shape { constructor() { this.x = 1; super("b"); } }
try { new Broken(); } catch (e) { print(e); }
class NoSuper extends Shape { constructor() { } }
try { new NoSuper(); } catch (e) { print(e); }
class Stack extends Array { peek() { return this[this.length - 1]; } }
const s = Stack.from([1, 2, 3]);
const m = s.map(x => x * 10);
print(s.peek(), m.peek(), m instanceof Stack, Array.isArray(m));
$ jsc inherit.js
circle(3.14) [square(4.00)]
true true true false
true true
ReferenceError: 'super()' must be called in derived constructor before accessing |this| or returning non-object.
ReferenceError: 'super()' must be called in derived constructor before accessing |this| or returning non-object.
3 30 true true
Shape.toString calls this.area(), and the subclass version runs: the lookup starts at the object, so the most specific method wins. Touching this before super(), or never calling super(), is the same ReferenceError. Extending Array works, and map on a Stack even returns a Stack.
instanceof does not look at the class name. It asks: "is Circle.prototype somewhere on this object's chain?" That has consequences:
class Duck {}
const d = new Duck();
print(d instanceof Duck);
Duck.prototype = {}; // ignored: class prototype is non-writable
print(d instanceof Duck);
function Goose() {}
const g = new Goose();
Goose.prototype = {}; // allowed for plain functions
print(g instanceof Goose);
// another realm (like an iframe): jsc's createGlobalObject()
const other = createGlobalObject();
const arr = other.eval("[1, 2, 3]");
print(arr instanceof Array, Array.isArray(arr));
// duck typing via Symbol.hasInstance
class Iterable { static [Symbol.hasInstance](o) { return o != null && typeof o[Symbol.iterator] === "function"; } }
print("abc" instanceof Iterable, new Map() instanceof Iterable, {} instanceof Iterable);
try { print(d instanceof {}); } catch (e) { print(e); }
$ jsc instanceof.js
true
true
false
false true
true true false
TypeError: {} is not a function. (evaluating 'd instanceof {}')
An array from another global object (a "realm": in browsers, an iframe or a worker; createGlobalObject() is a jsc-only helper that simulates one) is not instanceof Array, because it has a different Array.prototype. Array.isArray is the reliable check. The same happens with objects that cross realms through postMessage-like paths in browsers (not run here).
Modules: what jsc can run, and what it cannot
A module is a file with its own scope that shares values only through export and import (ES2015). jsc runs a file as a module when it ends in .mjs or when you pass -m. Two files:
// counter.mjs
export let count = 0;
export function inc() { count++; }
export default class Greeter { hi(n) { return `hi ${n}`; } }
print("counter.mjs evaluated");
// main.mjs
import Greeter, { count, inc } from "./counter.mjs";
import * as again from "./counter.mjs"; // same module, not evaluated twice
print(count); inc(); inc();
print(count, again.count); // live binding
try { count = 10; } catch (e) { print(e); }
print(new Greeter().hi("module"));
print(typeof this, typeof globalThis);
const cfg = await Promise.resolve({ debug: true }); // top-level await
print("tla:", cfg.debug);
const lazy = await import("./counter.mjs");
print(lazy.count, lazy === again);
print(import.meta.url.replace(/^.*\/mod\//, "~/demo/"));
$ jsc main.mjs
counter.mjs evaluated
0
2 2
TypeError: Attempted to assign to readonly property.
hi module
undefined object
tla: true
2 true
~/demo/main.mjs
What this shows: a module runs once no matter how many times it is imported; an imported let is a live binding (it changed from 0 to 2 when the exporting module changed it) but read-only for the importer; top-level this is undefined; top-level await (ES2022) and import() (ES2020) work. We cut the absolute path from import.meta.url ourselves.
The errors you will meet when mixing styles:
$ jsc classic.js # line 1: import { inc } from "./counter.mjs";
Exception: SyntaxError: Unexpected token '{'. import call expects one or two arguments.
at classic.js:1
$ jsc -m classic.js # same file, as a module: works
counter.mjs evaluated
1
$ jsc -e 'const fs = require("fs")'
Exception: ReferenceError: Can't find variable: require
global code@[Command Line]:1:19
$ jsc noext.mjs # import { inc } from "./counter";
Exception: Error: Could not open file '~/demo/mod/counter'.
$ jsc badname.mjs # import { incr } from "./counter.mjs";
Exception: SyntaxError: Importing binding name 'incr' is not found.
$ jsc sloppy.mjs # x = 5;
Exception: ReferenceError: Can't find variable: x
module code@~/demo/mod/sloppy.mjs:1:2
Modules are always strict, so assigning to an undeclared variable is an error instead of creating a global. Browsers also need the full file name with extension; bundlers and Node's CommonJS resolver guess extensions, which is why "./counter" works in many projects and fails here.
CommonJS (require(), module.exports) is Node's older module system. It does not exist in jsc, and we did not run Node, so the following is described only: CommonJS loads synchronously and copies module.exports at the time of require, while ESM bindings are live; Node decides between the two by the .mjs/.cjs extension or the "type" field in package.json.
Common mistake 3: circular imports and the TDZ
What people write: two modules that import each other's constants at the top level.
// a.mjs
import { B_NAME } from "./b.mjs";
export const A_NAME = "A";
print("a.mjs sees", B_NAME);
// b.mjs
import { A_NAME } from "./a.mjs";
export const B_NAME = "B";
print("b.mjs sees", A_NAME); // a.mjs has not run yet
What actually happens: jsc starts a.mjs, which first needs b.mjs, which reads A_NAME before a.mjs has executed its const line. The binding exists but is still in its temporal dead zone (TDZ, from lecture 2):
$ jsc a.mjs
Exception: ReferenceError: Cannot access 'A_NAME' before initialization.
module code@~/demo/mod/b.mjs:3:27
Fix: break the cycle (move shared constants into a third module), or only read the imported value later, inside a function that runs after both modules finished. With b.mjs exporting function hello() { return "b.mjs sees " + A_NAME; } and a.mjs calling it, the output became a.mjs sees B | b.mjs sees A.
Decision table
| Situation | Use | Why |
|---|---|---|
| Data with behaviour, many instances | class with prototype methods |
One copy of each method; 40 B/instance in our test |
| Method handed to callbacks (UI handlers) | Arrow field or bind once |
Keeps this; costs memory per instance |
| Truly hidden state, brand checks | #private fields, #x in obj |
Not reachable, not serialized |
| Just a bag of values | Object literal | No class needed; JSON round-trips cleanly |
| Code sharing across files | ES modules (.mjs or -m here) |
Live bindings, strict mode, works in browsers |
| "Is this an array?" | Array.isArray |
instanceof fails across realms |
| Two modules need each other's constants | Third shared module | Avoids the TDZ ReferenceError |
Exercises
- Without running it, predict the output, then check in jsc:
const base = { greet() { return "hi " + this.n; } }; const o = Object.create(base); o.n = "A"; const fn = o.greet; print(o.greet()); print(typeof fn.call({ n: "B" })); - Rewrite the setter from common mistake 1 so that
u.name = " lee "stores"lee",u.namereads it back, andJSON.stringify(u)prints{"name":"lee"}.
Answers
hi A, thenstring.fn.call({ n: "B" })returns"hi B";callsetsthisexplicitly.- One solution (checked in jsc):
class User {
#name = "";
set name(v) { this.#name = String(v).trim(); }
get name() { return this.#name; }
toJSON() { return { name: this.#name }; }
}
const u = new User(); u.name = " lee ";
print(u.name, JSON.stringify(u)); // lee {"name":"lee"}
Honest limits / version notes
- One engine. Everything ran on JavaScriptCore from macOS 26.6.2 only. Error messages are engine-specific (V8 in Chrome and Node words them differently); the behaviour shown is defined by the ECMAScript specification.
- Versions:
class,extends,super,Symbol.hasInstanceand ES modules are ES2015. Dynamicimport()andimport.metaare ES2020. Public and private fields, private methods, static blocks,#x in obj,Object.hasOwnand top-levelawaitare ES2022. All worked in this jsc; we did not test older engines. - jsc-only helpers:
print,fullGC,gcHeapSize,createGlobalObjectand the-mflag exist only in the jsc shell. The memory numbers come from JavaScriptCore's heap accounting and will differ in V8. - Not run here: Node.js and its CommonJS loader, browsers,
<script type="module">, bundlers, TypeScript. Extension rules and error messages there are described, not demonstrated.
Previous: Lecture 3: objects, arrays and their methods · Next: Lecture 5: async, promises and the event loop
Series: 1 types and equality · 2 functions, scope and closures · 6 performance, memory and numbers · Related: Java classes, records and interfaces · Dart classes, mixins and extensions · the programming learning roadmap
FAQ
Is a JavaScript class just a function with a prototype?
Mostly, but not entirely. typeof a class is "function" and its methods live on ClassName.prototype, exactly like the older constructor-function pattern. The differences are real, though: calling a class without new throws "Cannot call a class constructor Point without |new|", class methods are not enumerable so for...in skips them, the class body is always strict mode, and ClassName.prototype cannot be reassigned.
Why do I get "undefined is not an object (evaluating 'this.count')"?
You took a method off its object (const f = obj.inc, or setTimeout(obj.inc, 0)) and called it bare. this is decided by how a function is called, so it was undefined inside the strict-mode class body. Fix it with obj.inc.bind(obj), a wrapper arrow (() => obj.inc()), or an arrow-function class field; the last one costs memory per instance (249 vs 40 bytes per object in our measurement).
Can JavaScriptCore's jsc shell run ES modules and require()?
It runs ES modules: files ending in .mjs, or any file with jsc -m, including import/export, live bindings, dynamic import() and top-level await. It has no require, module or exports, so CommonJS fails with "ReferenceError: Can't find variable: require". Node.js was not run for this article.