Class Fields & Initialization
Before public class fields landed (ES2022, widely shipped well before that in browsers and Node), every piece of instance state had to be assigned inside the constructor. Class fields let you declare and initialize instance properties directly in the class body, right where you can see them. This makes a class’s shape obvious at a glance, reduces constructor boilerplate, and — when combined with arrow functions — gives you methods that stay bound to the instance no matter how they’re called.
Declaring public instance fields
A public field is written in the class body as a property name, optionally followed by = and an initializer expression. Each field is created on every new instance, and the initializer runs once per instance. You do not use let, const, var, or this. — just the name.
class Counter {
count = 0;
label = "clicks";
history = [];
increment() {
this.count += 1;
this.history.push(this.count);
}
}
const c = new Counter();
c.increment();
c.increment();
console.log(c.count, c.label, c.history);
Output:
2 clicks [ 1, 2 ]
A field with no initializer (name;) is still created and set to undefined, which usefully documents that the property exists. Note that history = [] produces a fresh array per instance — initializers run per object, so two Counter instances never share the same array.
Initialization order vs the constructor
Field initializers do not run at some vague “class load” time — they run during construction, in source order, and the timing relative to the constructor matters. In a base class (one with no extends), fields are initialized at the very start of construction, before the constructor body executes. So by the time your constructor code runs, every field already holds its initial value.
class Config {
retries = 3;
timeout = this.retries * 1000; // can reference earlier fields
constructor(overrides = {}) {
console.log("before:", this.retries, this.timeout);
Object.assign(this, overrides);
console.log("after:", this.retries, this.timeout);
}
}
const cfg = new Config({ retries: 5 });
Output:
before: 3 3000
after: 5 3000
Two things to notice. First, a later initializer can read an earlier field (timeout uses this.retries) because fields run top to bottom. Second, the constructor sees fully-initialized fields, so passing retries: 5 overrides the value after timeout was already computed from the default — initializers do not re-run.
In a derived class (one that uses extends), instance fields are initialized immediately after super() returns. That means you must call super() before this — and your subclass fields — are available.
class Animal {
legs = 4;
constructor(name) {
this.name = name;
}
}
class Dog extends Animal {
species = "dog"; // initialized right after super() returns
constructor(name) {
super(name);
console.log(this.species, this.legs);
}
}
new Dog("Rex");
Output:
dog 4
Gotcha: In a derived class, referencing
this(or any subclass field) before callingsuper()throws aReferenceError. Always callsuper()first.
Arrow-function fields for bound methods
A regular method gets its this from how it is called, so passing a method as a callback usually loses the instance binding. Because a class field’s initializer runs with this bound to the new instance, assigning an arrow function to a field captures that this permanently — the method stays bound even when detached.
class Button {
clicks = 0;
// Regular method: `this` depends on the call site
handleLoose() {
this.clicks += 1;
}
// Arrow field: `this` is permanently the instance
handleBound = () => {
this.clicks += 1;
};
}
const btn = new Button();
const loose = btn.handleLoose;
const bound = btn.handleBound;
bound(); // works — `this` is btn
console.log(btn.clicks); // 1
try {
loose(); // `this` is undefined in strict mode
} catch (err) {
console.log(err.constructor.name);
}
Output:
1
TypeError
This pattern is common in event handlers and UI frameworks where methods are passed around as callbacks. The trade-off is shown below.
| Aspect | Regular method | Arrow-function field |
|---|---|---|
| Lives on | The prototype (shared) | Each instance (per object) |
this binding | Call-site dependent | Locked to the instance |
| Safe to pass as callback | No (needs .bind) | Yes |
| Memory per instance | None extra | One function per field |
| Overridable by subclass | Yes (prototype) | Only by re-assigning the field |
Tip: Prefer regular prototype methods by default; reach for arrow fields only for methods you genuinely pass as detached callbacks. For one-off cases,
this.method.bind(this)or wrapping in an arrow at the call site avoids the per-instance cost.
Computed field names
Field names can be computed with square brackets, just like object literals — handy when the key comes from a constant or symbol.
const KEY = "status";
class Job {
[KEY] = "pending";
[Symbol.iterator] = function* () {
yield this.status;
};
}
const job = new Job();
console.log(job.status, [...job]);
Output:
pending [ 'pending' ]
Best Practices
- Declare every instance field in the class body so the object’s shape is documented in one place, even fields you set later in the constructor.
- Use a bare
name;(no initializer) to signal a field that the constructor will populate. - Remember initializers run per instance and top to bottom — leverage that to derive one field from another, but never rely on them re-running.
- In derived classes, always call
super()before touchingthisor any subclass field. - Default to prototype methods; use arrow-function fields only for methods passed around as callbacks, accepting the per-instance memory cost.
- For truly hidden state, prefer
#privatefields over_-prefixed public fields.