JAVASCRIPT / PROTOTYPES, CLASSES, AND INHERITANCE
Static fields and methods on classes
Define static fields, methods, and initialization blocks, know why instances cannot see them, and use this inside statics to write subclass-aware factories.
What you will learn
- Place type-level helpers with static; instance property lookup never reaches them
- Write factories as static from(x) { return new this(x) } so subclasses stay correct
- Explain why Sub.helper() works: extends links Sub to Base, not just the prototypes
- Spot the shadowing bug when a subclass assigns to an inherited static field
Understanding Static fields and methods on classes
A class body builds two objects at once: the constructor function Session and the object sitting at Session.prototype. Anything marked static becomes an own property of the constructor function, while ordinary methods land on Session.prototype and fields land on each instance. That one placement rule explains the rest: an instance's lookup path runs instance -> Session.prototype -> Object.prototype and never visits Session itself, so session.count is undefined and session.fromRecord() throws.
Inside a static method, this is the class the method was called on. That matters because extends creates two links, not one: Sub.prototype inherits from Base.prototype, and Sub itself inherits from Base. So Sub.helper() finds Base's static method through the constructor chain and runs it with this === Sub, which is why a factory written as static from(x) { return new this(x) } hands back a Sub when called on Sub and a Base when called on Base.
Because statics live on a single object, static fields are shared state, and inheritance shares them by reference rather than by copy. Mutating a shared value with this.cache.set(...) is visible to the base class and to every sibling subclass, while assigning with this.total++ writes an own property on the subclass and quietly forks the value. Reserve statics for what belongs to the type itself, such as factories, parsers, registries, tuning constants, and validators, and keep per-object data in instance fields, since a static has exactly one copy.
class Session {
static count = 0; // lives on Session itself
constructor(user) {
this.user = user;
this.id = ++Session.count;
}
static fromRecord(record) { // no instance exists yet
return new this(record.user);
}
label() { // lives on Session.prototype
return `${this.id}:${this.user}`;
}
}
const a = new Session('ada');
const b = Session.fromRecord({ user: 'grace' });
console.log(a.label(), b.label());
console.log('created:', Session.count);
console.log('instance sees count?', a.count, '| instance sees factory?', typeof a.fromRecord);
console.log('own of Session:', Object.hasOwn(Session, 'fromRecord'));
console.log('own of Session.prototype:', Object.hasOwn(Session.prototype, 'fromRecord'));Static members are own properties of the constructor object, so they are reachable through the class and its subclasses but never through instances.
Worked examples
new this in a static factory
Shows that statics are inherited by subclasses and that this inside a static method is the class it was called on.
class Shape {
static create(...args) {
return new this(...args);
}
static describe() {
return `factory for ${this.name}`;
}
}
class Circle extends Shape {
constructor(r) {
super();
this.r = r;
}
area() {
return Math.PI * this.r ** 2;
}
}
console.log(Shape.describe());
console.log(Circle.describe());
const c = Circle.create(2);
console.log(c instanceof Circle, c.area().toFixed(2));
console.log(Object.getPrototypeOf(Circle) === Shape);Example explained
Line 1Circle.describe() is never redefined; it is found on Shape because extends sets Circle's own prototype to Shape.
Line 2this.name differs between the two calls because this is the class used at the call site, so it reads Circle.name the second time.
Line 3new this(...args) in create becomes new Circle(2), so the factory returns a Circle without Shape knowing that Circle exists.
Line 4Object.getPrototypeOf(Circle) === Shape is the constructor-to-constructor link that carries static members, separate from Circle.prototype -> Shape.prototype.
Shared static state versus a shadowed copy
Contrasts mutating an inherited static field with assigning to it from a subclass.
class Base {
static log = [];
static total = 0;
static note(tag) {
this.log.push(tag); // mutation
this.total++; // read, then assignment
}
}
class Sub extends Base {}
Sub.note('a');
Sub.note('b');
console.log('Base.log:', Base.log.join(','));
console.log('same array:', Sub.log === Base.log);
console.log('Base.total:', Base.total, 'Sub.total:', Sub.total);
console.log('own total on Sub:', Object.hasOwn(Sub, 'total'));Example explained
Line 1this.log.push(tag) never assigns to this, so it mutates the one array object stored on Base, which Sub only borrows.
Line 2this.total++ is a read followed by a write: the read falls through to Base, but the write always lands on the receiver, Sub.
Line 3From that first write on, Sub has its own total that shadows Base.total, so the two counters disagree forever.
Line 4Writing Base.total++ inside note would keep a single shared counter for every subclass.
static blocks for setup a field cannot express
Uses a static initialization block to derive one static from others when the class is defined.
class Palette {
static names = ['dawn', 'noon', 'dusk'];
static byName = new Map();
static {
Palette.names.forEach((name, i) => Palette.byName.set(name, i));
Palette.count = Palette.names.length;
}
static lookup(name) {
return Palette.byName.has(name) ? Palette.byName.get(name) : -1;
}
}
console.log(Palette.lookup('dusk'));
console.log(Palette.lookup('midnight'));
console.log(Palette.count);Example explained
Line 1Static fields and static blocks run in textual order, so the block can already read names and byName declared above it.
Line 2The block runs exactly once, when the class definition is evaluated, not once per instance.
Line 3Palette.count is created inside the block, proving a static is just a property on the constructor object rather than something the body must declare.
Line 4A field initializer is a single expression, whereas a block can loop, branch, or use try/catch during class setup.
Important notes
Static methods are non-enumerable like prototype methods, but static fields are enumerable: for the Session class above, Object.keys(Session) returns ['count'] and does not mention fromRecord.
static describes where a property lives, not that it is constant. Session.count = 99 and even Session.fromRecord = null are ordinary writable properties, and a static may share a name with an instance member because the two sit on different objects.
Common mistakes
Calling a static through an instance: session.fromRecord({ user: 'ada' }) throws TypeError: session.fromRecord is not a function, because the constructor object is not on an instance's prototype chain.
Reaching for instance data inside a static method: in static describe() { return this.user; } this is the class, so the result is undefined, and reading an instance #field there throws a TypeError instead.
Bumping an inherited counter from a subclass with Sub.total++ creates an own total on Sub and leaves Base.total unchanged, so aggregate counts silently drift apart.
Try it yourself
Change, predict, then run
In a browser console, write a Point class with a static parse(text) factory that turns '3,4' into an instance, then declare class Tagged extends Point {} and make Tagged.parse('3,4') instanceof Tagged log true without writing a second parse.
Open the JavaScript workspaceCheck your understanding
Base declares static tally = 0 and static bump() { this.tally++; }. After class Sub extends Base {} and two calls to Sub.bump(), what do Base.tally and Sub.tally hold?
- Both are 2, because Sub and Base share the one tally property
- Base.tally is 2 and Sub.tally is undefined, because static fields are not inherited
- Base.tally is 0 and Sub.tally is 2, because the first write created an own tally on Sub
- Both stay 0, because this is undefined inside a static method so both writes are lost
Show answer
Reading this.tally does travel up the constructor chain to Base, but an assignment always creates or updates the property on the receiver, which is Sub; from then on Sub.tally shadows Base.tally, giving 0 and 2. The first option is tempting because the initial read really does come from Base, yet a write through Sub never mutates Base's property.