Lesson 03 / 17
Class Syntax
Class declaration's exact counterpart on the prototype chain, the static side's inheritance, method enumerability, and the real differences syntax brings.
Contents
The previous lesson set up a two-layer chain by hand. It required three separate
operations: binding the sub-prototype to the super-prototype, repairing the constructor
property, and calling the super-constructor from inside the sub-constructor. When one of
the three is forgotten, the error shows up at runtime, often in a distant place.
Class syntax gathers these three operations into a single declaration. This lesson shows, with the previous lesson’s tests, which prototype operation each line of a class declaration corresponds to, then separates out the aspects of the syntax that are truly new. Its main claim has to be stated up front: a class is the syntactic face of the prototype chain; there is no different object model underneath it.
Same Chain, Single Declaration
The measurement record and the calibrated measurement, this time written with a class declaration.
class MeasurementRecord { constructor(sensor, value, time) { this.sensor = sensor; this.value = value; this.time = time; } format() { return `${this.sensor}@${this.time}: ${this.value}`; } reading() { return this.value; } } class CalibratedMeasurement extends MeasurementRecord { constructor(sensor, value, time, offset) { super(sensor, value, time); this.offset = offset; } reading() { return super.reading() + this.offset; } } const calibrated = new CalibratedMeasurement("S-03", 21.4, 1120, -0.4); console.log(calibrated.format()); console.log(calibrated.reading()); console.log(typeof MeasurementRecord); console.log(Object.getPrototypeOf(calibrated) === CalibratedMeasurement.prototype); console.log(Object.getPrototypeOf(CalibratedMeasurement.prototype) === MeasurementRecord.prototype); console.log(calibrated.constructor === CalibratedMeasurement); console.log(Object.keys(calibrated).join(","));
S-03@1120: 21.4 21 function true true true sensor,value,time,offset
The output’s first two lines are the same as the previous lesson’s output. The remaining lines expose the structure the syntax sets up:
typeof MeasurementRecordis"function"— a class declaration does not produce a new value kind, it produces a function.- The instance’s prototype is the
CalibratedMeasurement.prototypeobject;extendsmakes this object’s prototypeMeasurementRecord.prototype. - The
constructorproperty points to the right function; no manual repair is needed. - The instance’s own properties are only the four data fields. The methods stand not on
the instance, but on the
prototypeobject.
The super.reading() call is also explained by the chain: it starts the lookup not
through this, but from a prototype one layer up. In the previous lesson this job was
written by hand as MeasurementRecord.prototype.reading.call(this); super is the name
for the same job.
The Static Side
A class declaration writes members marked with the static keyword not to instances but
to the class function itself. A real difference from the manually built pattern shows up
here: extends binds not only the prototype objects but the class functions too.
class MeasurementRecord { static measurementUnit = "celsius"; static fromText(line) { const [sensor, value, time] = line.split(";"); return new this(sensor, Number(value), Number(time)); } constructor(sensor, value, time) { this.sensor = sensor; this.value = value; this.time = time; } format() { return `${this.sensor}@${this.time}: ${this.value}`; } } class CalibratedMeasurement extends MeasurementRecord {} const record = MeasurementRecord.fromText("S-01;21.4;1000"); const child = CalibratedMeasurement.fromText("S-03;22.1;1120"); console.log(record.format()); console.log(child.format()); console.log(child instanceof CalibratedMeasurement); console.log(CalibratedMeasurement.measurementUnit); console.log(Object.getPrototypeOf(CalibratedMeasurement) === MeasurementRecord); console.log(Object.hasOwn(CalibratedMeasurement, "measurementUnit"));
S-01@1000: 21.4 S-03@1120: 22.1 true celsius true false
The last two lines sum up what is happening: the CalibratedMeasurement class does not
carry the measurementUnit property itself, it inherits it through its prototype,
MeasurementRecord. So a class declaration sets up two chains — one between
prototype objects for instances, one between class functions. In the previous lesson’s
manual setup, the second one was never set up at all.
The new this(...) expression inside the fromText method is the result of this second
chain: this inside a static method is bound to whichever class it was called through,
so when called from a subclass, an instance of the subclass is produced.
Method Enumerability
In the first lesson it was seen that a for...in loop also lists the format name
coming from the prototype. This does not happen for methods defined with class syntax.
class MeasurementRecord { constructor(sensor, value) { this.sensor = sensor; this.value = value; } format() { return `${this.sensor}: ${this.value}`; } } function OldMeasurementRecord(sensor, value) { this.sensor = sensor; this.value = value; } OldMeasurementRecord.prototype.format = function () { return `${this.sensor}: ${this.value}`; }; const newRecord = new MeasurementRecord("S-01", 21.4); const oldRecord = new OldMeasurementRecord("S-01", 21.4); console.log(Object.keys(MeasurementRecord.prototype).join(",") || "(empty)"); console.log(Object.keys(OldMeasurementRecord.prototype).join(",") || "(empty)"); console.log(Object.getOwnPropertyNames(MeasurementRecord.prototype).join(",")); const newKeys = []; for (const key in newRecord) newKeys.push(key); const oldKeys = []; for (const key in oldRecord) oldKeys.push(key); console.log(newKeys.join(",")); console.log(oldKeys.join(","));
(empty) format constructor,format sensor,value sensor,value,format
Class methods exist on the prototype — the third line shows this — but they are
defined as non-enumerable; this is why Object.keys and for...in skip them. A
manually written assignment, on the other hand, produces an enumerable property and leaks
into the loop. What enumerability is and how it is manually controlled is the subject of
the next lesson.
The Real Differences the Syntax Brings
A class declaration is not only a shorthand; it also changes a few behaviors. The two most visible ones can be tested directly.
function oldRecord(sensor) { this.sensor = sensor; } const beforeDeclaration = new oldRecord("S-01"); console.log(beforeDeclaration.sensor); try { new MeasurementRecord("S-01"); } catch (error) { console.log(error.constructor.name); } class MeasurementRecord { constructor(sensor) { this.sensor = sensor; } } console.log(new MeasurementRecord("S-01").sensor); try { MeasurementRecord("S-01"); } catch (error) { console.log(error.constructor.name); }
S-01 ReferenceError S-01 TypeError
The first difference is hoisting. A function declaration can be called before its
definition; a class declaration cannot, because its name is in the temporal dead zone
until the declaration. This is the same behavior described for let and const in the
Naming and Scope Rules lesson of the JavaScript Fundamentals course.
The second difference is the call form. A constructor function can be called without
new, and in that case this binds to an unexpected value; a class constructor throws
an error when called without new. The third difference does not show up in the output:
all code inside a class body runs in strict mode, and silent errors like assigning to an
undeclared variable do not stay silent here.
Private Fields
In the manually built pattern, there was no language-level way to hide an object’s internal state; naming conventions (a leading underscore) only signaled intent. Class syntax brings private fields, whose name starts with a hash sign. These are not ordinary properties — they appear neither on the prototype chain nor in the object’s property listing.
class MeasurementRecord { #offset; constructor(sensor, value, offset = 0) { this.sensor = sensor; this.value = value; this.#offset = offset; } get reading() { return this.value + this.#offset; } hasOffset() { return this.#offset !== 0; } static offsetVisible(obj) { return #offset in obj; } } const record = new MeasurementRecord("S-01", 21.4, -0.4); console.log(record.reading); console.log(record.hasOffset()); console.log(Object.keys(record).join(",")); console.log(JSON.stringify(record)); console.log(Object.getOwnPropertyNames(record).join(",")); console.log(MeasurementRecord.offsetVisible(record)); console.log(MeasurementRecord.offsetVisible({ sensor: "S-02", value: 19.8 })); console.log(Object.getOwnPropertyNames(MeasurementRecord.prototype).join(","));
21
true
sensor,value
{"sensor":"S-01","value":21.4}
sensor,value
true
false
constructor,reading,hasOffset
A private field appears in no listing operation and does not enter serialization; trying
to access it from outside the class body does not even produce a runtime error, the code
cannot be parsed at all. Access is possible only from the body of the class that declares
the field. The #offset in obj form asks whether an object carries this field, without
raising an error.
The last line shows one more detail: reading is defined as an accessor and yet it
stands on the prototype. That is, the expression record.reading looks like a property
read but runs a function found on the chain. How accessors are defined is the next
lesson’s second subject.
The language’s second path for encapsulation is closures, and they provide hiding through an entirely different mechanism, never touching the prototype; a comparison of the two will be made in the Closures lesson of the Context and Closures topic.
Summary
- A class declaration produces a function; instances are bound to a
prototypeobject and methods stand there. extendssets up two chains: between instance prototypes and between class functions; the second one enables inheriting static members.superis the expression that starts a lookup from a prototype one layer up; it is the counterpart of a manually writtencallinvocation.- Class methods are defined as non-enumerable on the prototype, so they do not enter
for...inandObject.keysresults. - The syntax’s real differences are: the temporal dead zone, the requirement of
new, strict mode, and private fields. - Private fields are not properties; they are not listed, not serialized, and accessible only from the declaring class’s body.
Next Step
This lesson ran into enumerability twice: class methods do not enter for...in output,
manually written assignments do. An accessor also appeared — a property that looks like a
read but runs a function. Both show that properties are not made up of just a value; they
carry flags alongside that determine their behavior. The next lesson takes up these
flags — writability, enumerability, configurability — and accessors directly.
To keep your progress and take notes, Log in
My notes
Log in to take notes.